Skip to main content

rite_runtime/
runner.rs

1//! Channel-driven ceremony executor built around the protocol vocabulary.
2//!
3//! Frontends (TUI, console, headless) interact with the executor
4//! exclusively through [`UiCommand`] and [`ExecEvent`]; the transcript is
5//! recorded inline as facts are emitted.
6//!
7//! # Architecture
8//!
9//! ```text
10//!         ┌──────────────────────────┐
11//!         │      Frontend thread     │
12//!         │  (TUI / console / etc.)  │
13//!         └──────────────────────────┘
14//!           ▲                   │
15//!  ExecEvent│                   │UiCommand
16//!           │                   ▼
17//!         ┌──────────────────────────┐
18//!         │      Executor thread     │
19//!         │  ┌────────────────────┐  │
20//!         │  │      Reporter      │──┼─→ TranscriptSink (inline)
21//!         │  └────────────────────┘  │
22//!         │      ▲                   │
23//!         │      │ reporter.fact()   │
24//!         │  ┌────────────────────┐  │
25//!         │  │   Action handlers  │  │
26//!         │  └────────────────────┘  │
27//!         └──────────────────────────┘
28//! ```
29//!
30//! The executor owns the [`TranscriptSink`] for the duration of the run and
31//! finalizes it before returning. Action handlers receive a `&mut Reporter`
32//! and emit transcript-worthy facts through it.
33
34use 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
63/// Gather the machine entropy `m` that seeds the ceremony entropy source.
64///
65/// Sourced directly from the host OS RNG ([`SysRng`]), independent of any
66/// ceremony backend, so a device the ceremony later challenges cannot
67/// influence its own challenge nonce. A dry run instead returns a fixed,
68/// clearly-labelled sentinel so a re-derived value can never be mistaken for
69/// one produced under real entropy.
70fn 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/// Errors that may surface from an [`Action`] handler.
85#[derive(Debug, Error)]
86pub enum ActionError {
87    /// Operator requested abort. Propagated from [`Reporter::check_abort`]
88    /// or [`Reporter::prompt`].
89    #[error("ceremony aborted by operator")]
90    Aborted,
91    /// Frontend channel disconnected. The executor unwinds to a failure.
92    #[error("frontend channel disconnected")]
93    Disconnected,
94    /// Transcript sink failed to persist a fact.
95    #[error("transcript write failed: {0}")]
96    Transcript(std::io::Error),
97    /// Underlying backend returned an error.
98    #[error("backend error: {0}")]
99    Backend(#[from] BackendError),
100    /// Catch-all for handler-specific failures.
101    #[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    /// Classify whether the step that failed with this error may be retried.
121    ///
122    /// Delegates to [`BackendError::retriability`] for [`ActionError::Backend`];
123    /// every other handler-level error is [`Retriability::Fatal`]. `Failed`
124    /// covers procedural failures (a `check_value` mismatch must never be
125    /// re-run, that would be tampering) as well as integrity and configuration
126    /// errors, all of which are non-retriable. `Aborted` is an operator decision
127    /// handled on the abort path and is never auto-retried, so it is classified
128    /// fatal here as a safe default.
129    #[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
141/// Deserialize the per-step parameter blob into a typed handler-specific struct.
142///
143/// Wraps `serde_json::from_value` and converts the error into
144/// [`ActionError::Failed`] with a consistent "invalid params: …" wording.
145///
146/// # Errors
147///
148/// Returns [`ActionError::Failed`] if `params` cannot be deserialized into `P`.
149pub 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::StepAborted(rite_model::StepId::new("")),
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
172/// Action trait, every ceremony step handler implements this.
173///
174/// Handlers emit facts and prompts through [`Reporter`] and return a
175/// [`StepResult`]. Transcript evidence is whatever facts the handler
176/// emits via [`Reporter::fact`] during execution; there is no separate
177/// evidence struct.
178pub trait Action: Send + Sync {
179    /// Metadata describing this action (type, category, description).
180    fn metadata(&self) -> ActionMetadata;
181
182    /// Apply per-step parameter defaults before validation.
183    fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
184
185    /// Execute the action.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`ActionError`] when the handler cannot complete, when the
190    /// operator aborts, or when the frontend disappears.
191    fn execute(
192        &self,
193        step: &StepInfo,
194        ctx: &HandlerContext,
195        params: &serde_json::Value,
196        reporter: &mut Reporter<'_>,
197        backend: Option<&mut dyn Backend>,
198    ) -> Result<StepResult, ActionError>;
199}
200
201/// Registry mapping action types to their handlers.
202#[derive(Default)]
203pub struct ActionRegistry {
204    actions: HashMap<ActionType, Arc<dyn Action>>,
205}
206
207impl ActionRegistry {
208    /// Create an empty registry.
209    #[must_use]
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Register an action handler.
215    pub fn register(&mut self, action: Arc<dyn Action>) {
216        let action_type = action.metadata().action_type;
217        self.actions.insert(action_type, action);
218    }
219
220    /// Look up the handler for an action type.
221    #[must_use]
222    pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
223        self.actions.get(action)
224    }
225
226    /// Iterate over registered action types.
227    pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
228        self.actions.keys()
229    }
230
231    /// Return action types used in the execution plan that have no
232    /// registered handler. Results are deduplicated and returned in
233    /// first-occurrence order.
234    #[must_use]
235    pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
236        let mut seen = std::collections::HashSet::new();
237        let mut result = Vec::new();
238        for step in steps {
239            if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
240                result.push(step.action);
241            }
242        }
243        result
244    }
245}
246
247/// Summary returned by [`Executor::run`] on a successful completion.
248#[derive(Debug)]
249pub struct ExecutionSummary {
250    /// Ceremony name from the DSL.
251    pub ceremony_name: String,
252    /// Number of steps that completed successfully.
253    pub steps_completed: usize,
254    /// Final transcript fingerprint.
255    pub transcript_fingerprint: TranscriptFingerprint,
256}
257
258/// Channel-driven executor. One per ceremony run.
259pub struct Executor {
260    ceremony: Ceremony,
261    registry: ActionRegistry,
262    backend_registry: BackendRegistry,
263    output_config: OutputConfig,
264    dry_run: bool,
265    startup: StartupSnapshot,
266    clock: Arc<dyn Clock>,
267}
268
269impl Executor {
270    /// Build an executor for a single ceremony run.
271    ///
272    /// `startup` carries the system identity and device environment the CLI
273    /// gathered at launch; the executor echoes them to the frontend as UI
274    /// signals at ceremony start and does not otherwise inspect them.
275    #[must_use]
276    pub fn new(
277        ceremony: Ceremony,
278        registry: ActionRegistry,
279        backend_registry: BackendRegistry,
280        output_config: OutputConfig,
281        dry_run: bool,
282        startup: StartupSnapshot,
283    ) -> Self {
284        Self {
285            ceremony,
286            registry,
287            backend_registry,
288            output_config,
289            dry_run,
290            startup,
291            clock: Arc::new(SystemClock),
292        }
293    }
294
295    /// Override the run clock. The default is [`SystemClock`]; tests inject a
296    /// fixed or stepping clock to make recorded event times deterministic.
297    #[must_use]
298    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
299        self.clock = clock;
300        self
301    }
302
303    /// Drive the ceremony to completion.
304    ///
305    /// Blocking. Intended to be invoked on a dedicated executor thread
306    /// spawned by the frontend. The `transcript_sink` is finalized before
307    /// this function returns, and its fingerprint is included in the
308    /// returned summary.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`ExecutionError`] for any condition that prevents the
313    /// ceremony from completing, validation failures, missing actions,
314    /// abort, backend errors, or transcript I/O failures.
315    pub fn run(
316        self,
317        cmd_rx: &Receiver<UiCommand>,
318        event_tx: &Sender<ExecEvent>,
319        mut transcript_sink: Box<dyn TranscriptSink>,
320    ) -> Result<ExecutionSummary, ExecutionError> {
321        validate_parameters(&self.ceremony)?;
322
323        let resolved_params: HashMap<ParamId, serde_json::Value> = self
324            .ceremony
325            .parameters
326            .iter()
327            .map(|(id, p)| (id.clone(), p.value.clone()))
328            .collect();
329
330        let roles: HashMap<RoleId, String> = self
331            .ceremony
332            .roles
333            .iter()
334            .map(|(id, r)| (id.clone(), r.name.clone()))
335            .collect();
336
337        let materials_map: HashMap<MaterialId, String> = self
338            .ceremony
339            .materials
340            .iter()
341            .map(|(id, m)| (id.clone(), m.display_name().to_string()))
342            .collect();
343
344        let ceremony_name = self.ceremony.metadata.name.clone();
345        // Keep a clock handle for the terminal facts below: `execute_inner`
346        // consumes `self`, so the reporter and the runner share this clone.
347        let clock = Arc::clone(&self.clock);
348        let mut reporter = Reporter::new(
349            event_tx,
350            cmd_rx,
351            transcript_sink.as_mut(),
352            Arc::clone(&clock),
353        );
354
355        let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
356
357        // Record the terminal fact first so it ends up on disk. Then
358        // finalize the sink (which only returns the chain head, no
359        // sidecar is written) and forward both the fact and the
360        // fingerprint to the UI.
361        match outcome {
362            Ok(counts) => {
363                let at = clock.now();
364                let completed = StepFact::CeremonyCompleted {};
365                transcript_sink
366                    .record(at, &completed)
367                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
368                let _ = event_tx.send(ExecEvent::Fact {
369                    at,
370                    fact: completed,
371                });
372                let fingerprint = transcript_sink
373                    .finalize()
374                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
375                let _ = event_tx.send(ExecEvent::Finalized {
376                    fingerprint: fingerprint.as_str().to_string(),
377                });
378                Ok(ExecutionSummary {
379                    ceremony_name,
380                    steps_completed: counts.completed,
381                    transcript_fingerprint: fingerprint,
382                })
383            }
384            Err(err) => {
385                let at = clock.now();
386                let record = err.to_error_record();
387                let failed = StepFact::CeremonyFailed { error: record };
388                let _ = transcript_sink.record(at, &failed);
389                let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
390                if let Ok(fingerprint) = transcript_sink.finalize() {
391                    let _ = event_tx.send(ExecEvent::Finalized {
392                        fingerprint: fingerprint.as_str().to_string(),
393                    });
394                }
395                Err(err)
396            }
397        }
398    }
399
400    #[allow(clippy::too_many_lines)]
401    fn execute_inner(
402        mut self,
403        reporter: &mut Reporter<'_>,
404        resolved_params: HashMap<ParamId, serde_json::Value>,
405        roles: HashMap<RoleId, String>,
406        materials_map: HashMap<MaterialId, String>,
407    ) -> Result<StepCounts, ExecutionError> {
408        let dry_run = self.dry_run;
409
410        reporter.fact(StepFact::CeremonyStarted {
411            name: self.ceremony.metadata.name.clone(),
412        })?;
413
414        // Establish the ceremony entropy source before any step can draw from
415        // it. This machine seed is run-metadata (the runner-emitted exception
416        // to "facts come from actions"); human contributions, by contrast, are
417        // authored `gather_entropy` steps that fold into the ratchet later.
418        let (m, source) = gather_machine_entropy(dry_run)?;
419        reporter.seed_entropy(&m);
420        reporter.fact(StepFact::EntropySeeded {
421            m: base16ct::lower::encode_string(&m),
422            source,
423            derivation: DERIVATION_V1.to_string(),
424        })?;
425
426        // Pre-ceremony overview: descriptive metadata for the UI's
427        // Overview screen. Sent as a UI-only signal, not a transcript
428        // fact, because the YAML is the source of truth for these fields
429        // and the run record shouldn't duplicate them.
430        reporter.signal(UiSignal::CeremonyOverview {
431            description: self.ceremony.metadata.description.clone(),
432            materials: self
433                .ceremony
434                .materials
435                .iter()
436                .map(|(_, m)| MaterialOverview::from_material(m))
437                .collect(),
438            step_count: self.ceremony.execution_plan.len(),
439        })?;
440
441        // Machine identity and device environment for the System tab. Both
442        // UI-only: identity that belongs in the transcript is recorded by the
443        // `machine_info` action, not fed in here. `Environment` is emitted
444        // once today but shaped to be re-emitted by a future live observer.
445        reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
446        reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
447
448        // Ceremony-start gate: let the operator review the overview
449        // before any side effect (material loading, first step body). The
450        // prompt is emitted with no current step set, so it carries
451        // `step: None` through to the frontend.
452        if !dry_run {
453            reporter.prompt(&Prompt::Continue {
454                hint: Some("Press Enter to start the ceremony".to_string()),
455            })?;
456        }
457
458        let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
459
460        // Load materials. Pre-step logs are attributed to no specific step.
461        for (id, material) in self.ceremony.materials.iter() {
462            let artifact = load_material_artifact(id.as_str(), material)?;
463            state = state.with_material(ArtifactId::new(id.as_str()), artifact);
464            let display_name = material.display_name();
465            reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
466        }
467
468        let has_acts = !self.ceremony.acts.is_empty();
469        let mut current_act: Option<ActId> = None;
470        let mut counts = StepCounts::default();
471
472        // Move the plan out so we can iterate it while still borrowing
473        // other fields of `self.ceremony` (sections, acts, outputs).
474        let plan = std::mem::take(&mut self.ceremony.execution_plan);
475        for step in &plan {
476            // Act boundary
477            if has_acts {
478                let step_act = self
479                    .ceremony
480                    .sections
481                    .get(&step.section)
482                    .and_then(|s| s.act.clone());
483                if step_act != current_act {
484                    if let Some(act_id) = &step_act
485                        && let Some(act) = self.ceremony.acts.get(act_id)
486                    {
487                        reporter.fact(StepFact::ActStarted {
488                            id: act_id.clone(),
489                            label: act
490                                .name
491                                .clone()
492                                .unwrap_or_else(|| act_id.as_str().to_string()),
493                        })?;
494                    }
495                    current_act = step_act;
496                }
497            }
498
499            reporter.set_current_step(Some(step.id.clone()));
500            let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
501            let role_name = self
502                .ceremony
503                .roles
504                .get(&role_id)
505                .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
506            reporter.fact(StepFact::StepStarted {
507                id: step.id.clone(),
508                label: step.step_label.clone(),
509                role: role_id,
510                role_name,
511            })?;
512
513            // Pacing: gate the step body on operator acknowledgement. The
514            // prompt fires with the step header already visible, so the
515            // operator confirms "ready for step X" rather than "what just
516            // happened was fine". `silent` skips it for auto-advancing
517            // bookkeeping steps; `dry_run` skips it for non-interactive
518            // verification.
519            if !step.silent && !dry_run {
520                reporter.prompt(&Prompt::Continue {
521                    hint: Some(format!("Press Enter to start step {}", step.step_label)),
522                })?;
523            }
524
525            // Look up handler
526            let handler = self
527                .registry
528                .get(&step.action)
529                .ok_or(ExecutionError::UnknownAction(step.action))?
530                .clone();
531
532            let ctx = state.handler_context();
533            let step_info = step_info_from(step);
534            let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
535            handler.apply_defaults(&mut params, &step_info);
536
537            let result = execute_step_with_retry(
538                handler.as_ref(),
539                step,
540                &step_info,
541                &ctx,
542                &params,
543                reporter,
544                &mut self.backend_registry,
545            )?;
546
547            // StepCompleted
548            reporter.fact(StepFact::StepCompleted {
549                id: step.id.clone(),
550                outcome: result.outcome.clone(),
551            })?;
552
553            if let StepOutcome::Completed { .. } = &result.outcome {
554                counts.completed = counts.completed.saturating_add(1);
555            }
556
557            state = merge_step_result(state, result);
558
559            // Write output-bound artifacts. The DSL declares which artifact
560            // a step `creates`; we promote that to disk if it is also a
561            // ceremony-level output.
562            if let Some(artifact_id) = &step.creates
563                && !dry_run
564            {
565                let output_id = OutputId::new(artifact_id.as_str());
566                if self.ceremony.outputs.contains(&output_id) {
567                    std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
568                        ExecutionError::OutputWriteFailed {
569                            name: "artifacts directory".to_string(),
570                            reason: e.to_string(),
571                        }
572                    })?;
573
574                    let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
575                        ExecutionError::OutputWriteFailed {
576                            name: artifact_id.as_str().to_string(),
577                            reason: "artifact not produced".to_string(),
578                        }
579                    })?;
580
581                    let (path, hash, _size, _mime_type) =
582                        write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
583
584                    reporter.fact(StepFact::ArtifactWritten {
585                        step: step.id.clone(),
586                        name: artifact_id.as_str().to_string(),
587                        path: path.clone(),
588                        sha256: hash,
589                    })?;
590                }
591            }
592        }
593
594        reporter.set_current_step(None);
595        drop(plan);
596
597        Ok(counts)
598    }
599}
600
601#[derive(Default, Debug, Clone, Copy)]
602struct StepCounts {
603    completed: usize,
604}
605
606/// Run one step, retrying transient failures under operator control.
607///
608/// Each attempt re-acquires the backend handle (it is moved into the handler)
609/// and snapshots the side-effect count beforehand: a transient error is
610/// retriable only if the attempt performed no work on the world yet (the
611/// conservative re-executability gate). Interaction records such as an
612/// answered prompt do not close the gate: a retried attempt simply prompts
613/// again. A retriable error within the step's [`RetryPolicy`] prompts the
614/// operator; any other failure terminates the run.
615fn execute_step_with_retry(
616    handler: &dyn Action,
617    step: &Step,
618    step_info: &StepInfo,
619    ctx: &HandlerContext,
620    params: &serde_json::Value,
621    reporter: &mut Reporter<'_>,
622    backend_registry: &mut BackendRegistry,
623) -> Result<StepResult, ExecutionError> {
624    let mut attempt: u32 = 1;
625    loop {
626        let side_effects_before = reporter.side_effects_emitted();
627
628        // Backend acquisition is part of the attempt: a failed lazy
629        // initialization (device unplugged, daemon down) carries the same
630        // retriability semantics as a backend error inside the handler.
631        let attempt_result = match &step_info.backend {
632            Some(name) => match backend_registry.get_mut(name) {
633                Ok(backend) => handler.execute(step_info, ctx, params, reporter, Some(backend)),
634                Err(e) => Err(ActionError::Backend(e)),
635            },
636            None => handler.execute(step_info, ctx, params, reporter, None),
637        };
638
639        let action_err = match attempt_result {
640            Ok(result) => return Ok(result),
641            // Abort is an operator decision, not an attempt failure: no
642            // StepAttemptFailed is recorded, the run terminates here.
643            Err(ActionError::Aborted) => return Err(ExecutionError::StepAborted(step.id.clone())),
644            Err(action_err) => action_err,
645        };
646
647        // Conservative re-executability gate: a step that already performed
648        // work on the world this attempt is not safely re-runnable.
649        let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
650        let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
651        let exec_err = action_error_to_execution(action_err, &step.id);
652        reporter.fact(StepFact::StepAttemptFailed {
653            step: step.id.clone(),
654            attempt,
655            error: exec_err.to_error_record(),
656        })?;
657
658        let policy_allows = match step.retry {
659            RetryPolicy::Never => false,
660            RetryPolicy::MaxAttempts(max) => attempt < max,
661            RetryPolicy::Prompt => true,
662        };
663
664        if retriable && policy_allows {
665            // Default `false`: the headless/dry-run driver answers the default,
666            // so non-interactive runs abort rather than loop on a deterministic
667            // error.
668            let answer = reporter.prompt(&Prompt::Confirm {
669                question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
670                default: Some(false),
671            })?;
672            if matches!(answer, Response::Bool(true)) {
673                attempt = attempt.saturating_add(1);
674                continue;
675            }
676        }
677        return Err(exec_err);
678    }
679}
680
681/// Map a handler-level [`ActionError`] to the execution-level error the run
682/// terminates with. `Aborted` is included for completeness; the executor
683/// special-cases it before calling this so it never records a step-attempt
684/// failure for an abort.
685fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
686    match err {
687        ActionError::Aborted => ExecutionError::StepAborted(step_id.clone()),
688        ActionError::Disconnected => ExecutionError::StepFailed {
689            step: step_id.clone(),
690            reason: "frontend channel disconnected".to_string(),
691        },
692        ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
693        ActionError::Backend(e) => ExecutionError::BackendError(e),
694        ActionError::Failed(reason) => ExecutionError::StepFailed {
695            step: step_id.clone(),
696            reason,
697        },
698    }
699}
700
701impl ExecutionError {
702    /// Convert this execution error into a transcript-friendly record.
703    ///
704    /// The audit `class` derives from the error's nature; for a backend error
705    /// it reuses the same retriability judgment the runtime uses for retry
706    /// (a retriable backend error is environmental).
707    fn to_error_record(&self) -> ErrorRecord {
708        let (class, kind) = match self {
709            ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
710            ExecutionError::StepAborted(_) => (ErrorClass::Abort, "aborted"),
711            ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
712            ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
713            ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
714            ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
715            ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
716            ExecutionError::MaterialLoadFailed { .. } => {
717                (ErrorClass::Integrity, "material_load_failed")
718            }
719            ExecutionError::OutputWriteFailed { .. } => {
720                (ErrorClass::Integrity, "output_write_failed")
721            }
722            ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
723            ExecutionError::BackendError(e) => {
724                let class = if e.retriability().is_retriable() {
725                    ErrorClass::Environmental
726                } else {
727                    ErrorClass::Integrity
728                };
729                (class, "backend_error")
730            }
731        };
732        ErrorRecord::new(class, kind, self.to_string())
733    }
734}
735
736fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
737    let missing: Vec<_> = ceremony
738        .parameters
739        .iter()
740        .filter(|(_, p)| p.value.is_null())
741        .map(|(id, _)| id.as_str().to_string())
742        .collect();
743    if missing.is_empty() {
744        Ok(())
745    } else {
746        Err(ExecutionError::ValidationFailed(format!(
747            "Parameters missing values: {}",
748            missing.join(", ")
749        )))
750    }
751}
752
753/// Fold a step's produced artifacts into the running [`ExecutionState`].
754fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
755    let mut artifacts = state.artifacts;
756    for (id, value) in result.artifacts {
757        artifacts.insert(id, value);
758    }
759    ExecutionState {
760        params: state.params,
761        roles: state.roles,
762        materials: state.materials,
763        dry_run: state.dry_run,
764        artifacts,
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use crossbeam_channel::unbounded;
771    use rite_model::ActionType;
772
773    use super::*;
774    use crate::actions::ActionCategory;
775    use crate::transcript_sink::InMemorySink;
776
777    #[test]
778    fn action_error_retriability_delegates_to_backend() {
779        assert_eq!(
780            ActionError::Backend(BackendError::TokenNotPresent).retriability(),
781            Retriability::Transient,
782        );
783        assert_eq!(
784            ActionError::Backend(BackendError::PinBlocked).retriability(),
785            Retriability::Fatal,
786        );
787    }
788
789    #[test]
790    fn handler_level_errors_are_fatal() {
791        assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
792        assert_eq!(
793            ActionError::Disconnected.retriability(),
794            Retriability::Fatal
795        );
796        assert_eq!(
797            ActionError::Failed("check_value mismatch".into()).retriability(),
798            Retriability::Fatal,
799        );
800    }
801
802    struct PingAction;
803
804    impl Action for PingAction {
805        fn metadata(&self) -> ActionMetadata {
806            ActionMetadata {
807                action_type: ActionType::Attest,
808                description: "test action that always succeeds",
809                category: ActionCategory::Verification,
810            }
811        }
812
813        fn execute(
814            &self,
815            _step: &StepInfo,
816            _ctx: &HandlerContext,
817            _params: &serde_json::Value,
818            reporter: &mut Reporter<'_>,
819            _backend: Option<&mut dyn Backend>,
820        ) -> Result<StepResult, ActionError> {
821            reporter.log(Icon::Info, "ping")?;
822            Ok(StepResult::completed("pinged".to_string()))
823        }
824    }
825
826    fn minimal_ceremony() -> Ceremony {
827        let yaml = r#"
828version: "0.2"
829name: "Test"
830roles:
831  participant:
832    person: "Alice"
833sections:
834  main:
835    role: ${role.participant}
836    steps:
837      ping:
838        action: attest
839        silent: true
840        with:
841          statement: "I confirm."
842"#;
843        rite_resolver::resolve(yaml, None)
844            .into_result()
845            .expect("resolve")
846    }
847
848    fn dry_run_output_config() -> OutputConfig {
849        OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
850    }
851
852    #[test]
853    fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
854        let ceremony = minimal_ceremony();
855        let mut registry = ActionRegistry::new();
856        registry.register(Arc::new(PingAction));
857
858        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
859        let (event_tx, event_rx) = unbounded::<ExecEvent>();
860        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
861
862        let executor = Executor::new(
863            ceremony,
864            registry,
865            BackendRegistry::new(),
866            dry_run_output_config(),
867            true,
868            StartupSnapshot::placeholder(),
869        );
870
871        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
872
873        // Drain events. With silent=true and dry_run=true, no prompts fire.
874        let mut facts = Vec::new();
875        while let Ok(ev) = event_rx.recv() {
876            if let ExecEvent::Fact { fact, .. } = ev {
877                facts.push(fact);
878            }
879        }
880
881        let summary = join.join().expect("executor join").expect("run ok");
882        assert_eq!(summary.steps_completed, 1);
883
884        let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
885        assert_eq!(
886            kinds,
887            vec![
888                "ceremony_started",
889                "entropy_seeded",
890                "step_started",
891                "step_completed",
892                "ceremony_completed",
893            ],
894        );
895
896        drop(cmd_tx);
897    }
898
899    #[test]
900    fn run_stamps_every_fact_from_the_injected_clock() {
901        use crate::test_support::{FixedClock, fixed_test_time};
902
903        let mut registry = ActionRegistry::new();
904        registry.register(Arc::new(PingAction));
905
906        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
907        let (event_tx, event_rx) = unbounded::<ExecEvent>();
908        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
909
910        let fixed = fixed_test_time();
911        let executor = Executor::new(
912            minimal_ceremony(),
913            registry,
914            BackendRegistry::new(),
915            dry_run_output_config(),
916            true,
917            StartupSnapshot::placeholder(),
918        )
919        .with_clock(Arc::new(FixedClock(fixed)));
920
921        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
922
923        let mut times = Vec::new();
924        while let Ok(ev) = event_rx.recv() {
925            if let ExecEvent::Fact { at, .. } = ev {
926                times.push(at);
927            }
928        }
929        join.join().expect("executor join").expect("run ok");
930
931        // Every fact carries the injected time, including the terminal
932        // `ceremony_completed`, which the runner emits outside `reporter.fact`.
933        assert!(!times.is_empty());
934        assert!(times.iter().all(|&t| t == fixed));
935
936        drop(cmd_tx);
937    }
938
939    #[derive(Debug, PartialEq, Eq)]
940    enum PacingTrace {
941        CeremonyStarted,
942        StepStarted(String),
943        ContinuePrompt { step: Option<String> },
944        StepCompleted,
945        CeremonyCompleted,
946    }
947
948    /// Two-step ceremony with pacing enabled: each step's body must be
949    /// gated on a `Continue` prompt that the executor emits **between**
950    /// `StepStarted` and the handler. After the last step there must be
951    /// no trailing pause, only `StepCompleted` and `CeremonyCompleted`.
952    #[test]
953    #[allow(clippy::too_many_lines)]
954    fn pacing_prompts_fire_at_step_start_not_step_end() {
955        let yaml = r#"
956version: "0.2"
957name: "Pacing"
958roles:
959  participant:
960    person: "Alice"
961sections:
962  main:
963    role: ${role.participant}
964    steps:
965      first:
966        action: attest
967        with:
968          statement: "first"
969      second:
970        action: attest
971        with:
972          statement: "second"
973"#;
974        let ceremony = rite_resolver::resolve(yaml, None)
975            .into_result()
976            .expect("resolve");
977        let mut registry = ActionRegistry::new();
978        registry.register(Arc::new(PingAction));
979
980        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
981        let (event_tx, event_rx) = unbounded::<ExecEvent>();
982        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
983
984        let executor = Executor::new(
985            ceremony,
986            registry,
987            BackendRegistry::new(),
988            dry_run_output_config(),
989            false, // dry_run=false so prompts actually fire
990            StartupSnapshot::placeholder(),
991        );
992
993        // Drive the frontend on a worker so the executor's prompt waits
994        // don't deadlock. Acknowledge every Continue that arrives and
995        // collect a trace of the events the executor emitted.
996        let frontend = std::thread::spawn({
997            let cmd_tx = cmd_tx.clone();
998            move || {
999                let mut trace = Vec::new();
1000                while let Ok(event) = event_rx.recv() {
1001                    match event {
1002                        ExecEvent::Fact {
1003                            fact: StepFact::CeremonyStarted { .. },
1004                            ..
1005                        } => {
1006                            trace.push(PacingTrace::CeremonyStarted);
1007                        }
1008                        ExecEvent::Fact {
1009                            fact: StepFact::StepStarted { label, .. },
1010                            ..
1011                        } => {
1012                            trace.push(PacingTrace::StepStarted(label));
1013                        }
1014                        ExecEvent::Fact {
1015                            fact: StepFact::StepCompleted { .. },
1016                            ..
1017                        } => {
1018                            trace.push(PacingTrace::StepCompleted);
1019                        }
1020                        ExecEvent::Fact {
1021                            fact: StepFact::CeremonyCompleted { .. },
1022                            ..
1023                        } => {
1024                            trace.push(PacingTrace::CeremonyCompleted);
1025                        }
1026                        ExecEvent::AwaitPrompt {
1027                            prompt_id,
1028                            prompt: Prompt::Continue { .. },
1029                            step,
1030                            ..
1031                        } => {
1032                            trace.push(PacingTrace::ContinuePrompt {
1033                                step: step.map(|s| s.as_str().to_string()),
1034                            });
1035                            let _ = cmd_tx.send(UiCommand::PromptResponse {
1036                                prompt_id,
1037                                response: crate::protocol::Response::Acknowledge,
1038                            });
1039                        }
1040                        _ => {}
1041                    }
1042                }
1043                trace
1044            }
1045        });
1046
1047        let summary = executor
1048            .run(&cmd_rx, &event_tx, sink)
1049            .expect("ceremony runs");
1050        assert_eq!(summary.steps_completed, 2);
1051        // Drop the local senders so the frontend thread sees the
1052        // event channel close and returns.
1053        drop(event_tx);
1054        drop(cmd_tx);
1055        let trace = frontend.join().expect("frontend join");
1056
1057        // Required order:
1058        //   CeremonyStarted, Continue{None},
1059        //   StepStarted(1), Continue{Some(1)}, StepCompleted,
1060        //   StepStarted(2), Continue{Some(2)}, StepCompleted,
1061        //   CeremonyCompleted
1062        let mut iter = trace.iter();
1063        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1064        assert_eq!(
1065            iter.next(),
1066            Some(&PacingTrace::ContinuePrompt { step: None })
1067        );
1068        let first_step = match iter.next() {
1069            Some(PacingTrace::StepStarted(label)) => label.clone(),
1070            other => panic!("expected first StepStarted, got {other:?}"),
1071        };
1072        assert_eq!(
1073            iter.next(),
1074            Some(&PacingTrace::ContinuePrompt {
1075                step: Some("first".to_string()),
1076            }),
1077            "first step's Continue prompt must fire after StepStarted and before the body",
1078        );
1079        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1080        let second_step = match iter.next() {
1081            Some(PacingTrace::StepStarted(label)) => label.clone(),
1082            other => panic!("expected second StepStarted, got {other:?}"),
1083        };
1084        assert_eq!(
1085            iter.next(),
1086            Some(&PacingTrace::ContinuePrompt {
1087                step: Some("second".to_string()),
1088            }),
1089        );
1090        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1091        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1092        assert_eq!(iter.next(), None, "no further events expected");
1093        // Steps are auto-numbered when there's only one section.
1094        assert_eq!(first_step, "1");
1095        assert_eq!(second_step, "2");
1096    }
1097
1098    #[test]
1099    fn aborts_when_action_returns_aborted() {
1100        struct AbortAction;
1101        impl Action for AbortAction {
1102            fn metadata(&self) -> ActionMetadata {
1103                ActionMetadata {
1104                    action_type: ActionType::Attest,
1105                    description: "aborts immediately",
1106                    category: ActionCategory::Verification,
1107                }
1108            }
1109            fn execute(
1110                &self,
1111                _step: &StepInfo,
1112                _ctx: &HandlerContext,
1113                _params: &serde_json::Value,
1114                _reporter: &mut Reporter<'_>,
1115                _backend: Option<&mut dyn Backend>,
1116            ) -> Result<StepResult, ActionError> {
1117                Err(ActionError::Aborted)
1118            }
1119        }
1120
1121        let ceremony = minimal_ceremony();
1122        let mut registry = ActionRegistry::new();
1123        registry.register(Arc::new(AbortAction));
1124
1125        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1126        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1127        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1128
1129        let executor = Executor::new(
1130            ceremony,
1131            registry,
1132            BackendRegistry::new(),
1133            dry_run_output_config(),
1134            true,
1135            StartupSnapshot::placeholder(),
1136        );
1137
1138        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1139
1140        let mut got_failed = false;
1141        while let Ok(ev) = event_rx.recv() {
1142            if let ExecEvent::Fact {
1143                fact: StepFact::CeremonyFailed { error, .. },
1144                ..
1145            } = ev
1146            {
1147                assert_eq!(error.kind, "aborted");
1148                got_failed = true;
1149            }
1150        }
1151        assert!(got_failed, "expected CeremonyFailed event");
1152
1153        let result = join.join().expect("executor join");
1154        assert!(matches!(result, Err(ExecutionError::StepAborted(_))));
1155    }
1156
1157    // ---- Retry loop tests ----
1158
1159    /// What [`FlakyAction`] does at the start of every attempt (failing or
1160    /// not, mirroring an action that always prompts before its backend call),
1161    /// to exercise the re-executability gate.
1162    enum BeforeFail {
1163        Nothing,
1164        /// Emit a side-effect fact (a backend operation): closes the gate.
1165        SideEffect,
1166        /// Issue a secret prompt (like a PIN entry): the answered prompt is
1167        /// recorded but must not close the gate.
1168        Prompt,
1169        /// Record an operator deviation note: recorded but must not close
1170        /// the gate.
1171        Deviation,
1172    }
1173
1174    /// Test action whose first `fails_remaining` executions fail with
1175    /// `error()`, then it succeeds.
1176    struct FlakyAction {
1177        fails_remaining: std::sync::Mutex<u32>,
1178        error: fn() -> ActionError,
1179        before_fail: BeforeFail,
1180    }
1181
1182    impl FlakyAction {
1183        fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1184            Arc::new(Self {
1185                fails_remaining: std::sync::Mutex::new(fails),
1186                error,
1187                before_fail,
1188            })
1189        }
1190    }
1191
1192    impl Action for FlakyAction {
1193        fn metadata(&self) -> ActionMetadata {
1194            ActionMetadata {
1195                action_type: ActionType::Attest,
1196                description: "flaky test action",
1197                category: ActionCategory::Verification,
1198            }
1199        }
1200
1201        fn execute(
1202            &self,
1203            step: &StepInfo,
1204            _ctx: &HandlerContext,
1205            _params: &serde_json::Value,
1206            reporter: &mut Reporter<'_>,
1207            _backend: Option<&mut dyn Backend>,
1208        ) -> Result<StepResult, ActionError> {
1209            let mut remaining = self.fails_remaining.lock().expect("lock");
1210            match self.before_fail {
1211                BeforeFail::Nothing => {}
1212                BeforeFail::SideEffect => {
1213                    reporter.fact(StepFact::BackendOperation {
1214                        step: step.id.clone(),
1215                        kind: "test_op".to_string(),
1216                        inputs: serde_json::Value::Null,
1217                        outputs: serde_json::Value::Null,
1218                        fingerprint: None,
1219                    })?;
1220                }
1221                BeforeFail::Prompt => {
1222                    reporter.prompt(&Prompt::Secret {
1223                        label: "Enter PIN".to_string(),
1224                    })?;
1225                }
1226                BeforeFail::Deviation => {
1227                    reporter.fact(StepFact::DeviationRecorded {
1228                        step: None,
1229                        text: "operator note".to_string(),
1230                    })?;
1231                }
1232            }
1233            if *remaining == 0 {
1234                return Ok(StepResult::completed("done".to_string()));
1235            }
1236            *remaining = remaining.saturating_sub(1);
1237            Err((self.error)())
1238        }
1239    }
1240
1241    fn transient() -> ActionError {
1242        ActionError::Backend(BackendError::TokenNotPresent)
1243    }
1244
1245    fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1246        let yaml = format!(
1247            r#"
1248version: "0.2"
1249name: "Retry"
1250roles:
1251  participant:
1252    person: "Alice"
1253sections:
1254  main:
1255    role: ${{role.participant}}
1256    steps:
1257      work:
1258        action: attest
1259        silent: true
1260        {retry_clause}
1261        with:
1262          statement: "x"
1263"#
1264        );
1265        rite_resolver::resolve(&yaml, None)
1266            .into_result()
1267            .expect("resolve")
1268    }
1269
1270    /// Run `ceremony` with `action`, answering every retry `Confirm` with
1271    /// `confirm_answer`. Returns the run result, the emitted facts, and the
1272    /// number of retry prompts seen.
1273    fn drive(
1274        ceremony: Ceremony,
1275        action: Arc<dyn Action>,
1276        confirm_answer: bool,
1277    ) -> (
1278        Result<ExecutionSummary, ExecutionError>,
1279        Vec<StepFact>,
1280        usize,
1281    ) {
1282        drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1283    }
1284
1285    /// Like [`drive`], with a caller-provided backend registry for steps
1286    /// that name a backend.
1287    fn drive_with_backends(
1288        ceremony: Ceremony,
1289        action: Arc<dyn Action>,
1290        confirm_answer: bool,
1291        backend_registry: BackendRegistry,
1292    ) -> (
1293        Result<ExecutionSummary, ExecutionError>,
1294        Vec<StepFact>,
1295        usize,
1296    ) {
1297        let mut registry = ActionRegistry::new();
1298        registry.register(action);
1299
1300        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1301        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1302        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1303
1304        let executor = Executor::new(
1305            ceremony,
1306            registry,
1307            backend_registry,
1308            dry_run_output_config(),
1309            true, // dry_run: skips pacing/start prompts, leaving only the retry Confirm
1310            StartupSnapshot::placeholder(),
1311        );
1312        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1313
1314        let mut facts = Vec::new();
1315        let mut confirms = 0usize;
1316        while let Ok(ev) = event_rx.recv() {
1317            match ev {
1318                ExecEvent::Fact { fact, .. } => facts.push(fact),
1319                ExecEvent::AwaitPrompt {
1320                    prompt_id,
1321                    prompt: Prompt::Confirm { .. },
1322                    ..
1323                } => {
1324                    confirms = confirms.saturating_add(1);
1325                    cmd_tx
1326                        .send(UiCommand::PromptResponse {
1327                            prompt_id,
1328                            response: Response::Bool(confirm_answer),
1329                        })
1330                        .expect("send confirm");
1331                }
1332                ExecEvent::AwaitPrompt {
1333                    prompt_id,
1334                    prompt: Prompt::Secret { .. },
1335                    ..
1336                } => {
1337                    cmd_tx
1338                        .send(UiCommand::PromptResponse {
1339                            prompt_id,
1340                            response: Response::Secret("123456".to_string().into()),
1341                        })
1342                        .expect("send secret");
1343                }
1344                _ => {}
1345            }
1346        }
1347        let result = join.join().expect("executor join");
1348        (result, facts, confirms)
1349    }
1350
1351    fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1352        facts
1353            .iter()
1354            .filter_map(|f| match f {
1355                StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1356                _ => None,
1357            })
1358            .collect()
1359    }
1360
1361    #[test]
1362    fn transient_failure_is_retried_then_succeeds() {
1363        let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1364        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1365
1366        let summary = result.expect("run ok");
1367        assert_eq!(summary.steps_completed, 1);
1368        assert_eq!(confirms, 1, "one retry prompt");
1369        assert_eq!(attempt_numbers(&facts), vec![1]);
1370        // The recorded attempt is classified environmental (a transient backend error).
1371        let StepFact::StepAttemptFailed { error, .. } = facts
1372            .iter()
1373            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1374            .expect("attempt fact")
1375        else {
1376            unreachable!()
1377        };
1378        assert_eq!(error.class, ErrorClass::Environmental);
1379        assert!(
1380            facts
1381                .iter()
1382                .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1383        );
1384    }
1385
1386    #[test]
1387    fn retry_is_blocked_once_a_side_effect_was_emitted() {
1388        // Transient error, but the attempt already performed a backend
1389        // operation: the conservative gate refuses to retry, so no prompt
1390        // fires.
1391        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1392        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1393
1394        assert!(result.is_err(), "run fails");
1395        assert_eq!(confirms, 0, "gate blocks the retry prompt");
1396        assert_eq!(attempt_numbers(&facts), vec![1]);
1397        assert!(
1398            facts
1399                .iter()
1400                .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1401        );
1402    }
1403
1404    #[test]
1405    fn prompting_before_a_transient_failure_stays_retriable() {
1406        // A PIN-style secret prompt answered before the failure records a
1407        // PromptAnswered fact, but answering a prompt is not a side effect:
1408        // the retry must still be offered, and the second attempt re-prompts.
1409        let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1410        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1411
1412        let summary = result.expect("run ok");
1413        assert_eq!(summary.steps_completed, 1);
1414        assert_eq!(confirms, 1, "the retry prompt fired");
1415        assert_eq!(attempt_numbers(&facts), vec![1]);
1416        let secret_answers = facts
1417            .iter()
1418            .filter(|f| {
1419                matches!(
1420                    f,
1421                    StepFact::PromptAnswered {
1422                        prompt: Prompt::Secret { .. },
1423                        ..
1424                    }
1425                )
1426            })
1427            .count();
1428        assert_eq!(secret_answers, 2, "each attempt records its own answer");
1429    }
1430
1431    #[test]
1432    fn a_deviation_note_does_not_block_retry() {
1433        let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1434        let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1435
1436        let summary = result.expect("run ok");
1437        assert_eq!(summary.steps_completed, 1);
1438        assert_eq!(confirms, 1, "the retry prompt fired");
1439    }
1440
1441    #[test]
1442    fn backend_acquisition_failure_is_retriable() {
1443        use std::sync::atomic::{AtomicU32, Ordering};
1444
1445        // Lazy backend initialization fails on the first probe (device
1446        // unplugged) and succeeds on the second: the operator must get the
1447        // same retry offer as for a transient error inside the handler.
1448        struct NullBackend {
1449            name: String,
1450        }
1451        impl Backend for NullBackend {
1452            fn name(&self) -> &str {
1453                &self.name
1454            }
1455            fn provider(&self) -> &'static str {
1456                "mock"
1457            }
1458            fn fingerprint(&self) -> String {
1459                format!("mock={}", self.name)
1460            }
1461        }
1462
1463        let probes = Arc::new(AtomicU32::new(0));
1464        let probes_in_factory = Arc::clone(&probes);
1465        let mut backends =
1466            crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1467                if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1468                    Err(BackendError::HardwareFailure("no device".to_string()))
1469                } else {
1470                    Ok(Box::new(NullBackend {
1471                        name: "token".to_string(),
1472                    }) as Box<dyn Backend>)
1473                }
1474            }));
1475        backends.declare(
1476            "token".to_string(),
1477            rite_sdk::BackendConfig {
1478                provider: "mock".to_string(),
1479                extra: serde_json::json!({}),
1480            },
1481        );
1482
1483        let yaml = r#"
1484version: "0.2"
1485name: "Retry"
1486backends:
1487  token:
1488    provider: mock
1489roles:
1490  participant:
1491    person: "Alice"
1492sections:
1493  main:
1494    role: ${role.participant}
1495    steps:
1496      work:
1497        action: attest
1498        backend: token
1499        silent: true
1500        with:
1501          statement: "x"
1502"#;
1503        let ceremony = rite_resolver::resolve(yaml, None)
1504            .into_result()
1505            .expect("resolve");
1506
1507        let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1508        let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1509
1510        let summary = result.expect("run ok after the operator retried");
1511        assert_eq!(summary.steps_completed, 1);
1512        assert_eq!(confirms, 1, "the retry prompt fired");
1513        assert_eq!(attempt_numbers(&facts), vec![1]);
1514        let StepFact::StepAttemptFailed { error, .. } = facts
1515            .iter()
1516            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1517            .expect("attempt fact")
1518        else {
1519            unreachable!()
1520        };
1521        assert_eq!(error.class, ErrorClass::Environmental);
1522        assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1523    }
1524
1525    #[test]
1526    fn retry_never_fails_immediately() {
1527        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1528        let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1529
1530        assert!(result.is_err());
1531        assert_eq!(confirms, 0, "retry: never never prompts");
1532        assert_eq!(attempt_numbers(&facts), vec![1]);
1533    }
1534
1535    #[test]
1536    fn retry_attempts_caps_total_tries() {
1537        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1538        let (result, facts, confirms) =
1539            drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1540
1541        assert!(result.is_err());
1542        // Attempt 1 fails and prompts; attempt 2 fails and the cap forbids a
1543        // further prompt.
1544        assert_eq!(confirms, 1);
1545        assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1546    }
1547
1548    #[test]
1549    fn declining_the_retry_prompt_terminates() {
1550        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1551        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1552
1553        assert!(result.is_err());
1554        assert_eq!(confirms, 1, "prompted once, operator declined");
1555        assert_eq!(attempt_numbers(&facts), vec![1]);
1556    }
1557
1558    fn fact_kind(fact: &StepFact) -> &'static str {
1559        match fact {
1560            StepFact::CeremonyStarted { .. } => "ceremony_started",
1561            StepFact::ActStarted { .. } => "act_started",
1562            StepFact::StepStarted { .. } => "step_started",
1563            StepFact::PromptAnswered { .. } => "prompt_answered",
1564            StepFact::BackendOperation { .. } => "backend_operation",
1565            StepFact::AttestationRecorded { .. } => "attestation_recorded",
1566            StepFact::ArtifactWritten { .. } => "artifact_written",
1567            StepFact::DeviationRecorded { .. } => "deviation_recorded",
1568            StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
1569            StepFact::StepCompleted { .. } => "step_completed",
1570            StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1571            StepFact::CeremonyFailed { .. } => "ceremony_failed",
1572            StepFact::EntropySeeded { .. } => "entropy_seeded",
1573            StepFact::EntropyContributed { .. } => "entropy_contributed",
1574            StepFact::EntropyDrawn { .. } => "entropy_drawn",
1575            _ => "unknown",
1576        }
1577    }
1578}