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::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
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                    // Record the location relative to the run directory so the
585                    // transcript stays portable and never embeds the operator's
586                    // filesystem layout. `verify` re-anchors artifacts under the
587                    // transcript's own `artifacts/` directory regardless.
588                    let recorded_path = match path.strip_prefix(self.output_config.base_dir()) {
589                        Ok(relative) => relative.to_path_buf(),
590                        Err(_) => path.clone(),
591                    };
592
593                    reporter.fact(StepFact::ArtifactWritten {
594                        step: step.id.clone(),
595                        name: artifact_id.as_str().to_string(),
596                        path: recorded_path,
597                        sha256: hash,
598                    })?;
599                }
600            }
601        }
602
603        reporter.set_current_step(None);
604        drop(plan);
605
606        Ok(counts)
607    }
608}
609
610#[derive(Default, Debug, Clone, Copy)]
611struct StepCounts {
612    completed: usize,
613}
614
615/// Run one step, retrying transient failures under operator control.
616///
617/// Each attempt re-acquires the backend handle (it is moved into the handler)
618/// and snapshots the side-effect count beforehand: a transient error is
619/// retriable only if the attempt performed no work on the world yet (the
620/// conservative re-executability gate). Interaction records such as an
621/// answered prompt do not close the gate: a retried attempt simply prompts
622/// again. A retriable error within the step's [`RetryPolicy`] prompts the
623/// operator; any other failure terminates the run.
624fn execute_step_with_retry(
625    handler: &dyn Action,
626    step: &Step,
627    step_info: &StepInfo,
628    ctx: &HandlerContext,
629    params: &serde_json::Value,
630    reporter: &mut Reporter<'_>,
631    backend_registry: &mut BackendRegistry,
632) -> Result<StepResult, ExecutionError> {
633    let mut attempt: u32 = 1;
634    loop {
635        let side_effects_before = reporter.side_effects_emitted();
636
637        // Backend acquisition is part of the attempt: a failed lazy
638        // initialization (device unplugged, daemon down) carries the same
639        // retriability semantics as a backend error inside the handler.
640        let attempt_result = match &step_info.backend {
641            Some(name) => match backend_registry.get_mut(name) {
642                Ok(backend) => handler.execute(step_info, ctx, params, reporter, Some(backend)),
643                Err(e) => Err(ActionError::Backend(e)),
644            },
645            None => handler.execute(step_info, ctx, params, reporter, None),
646        };
647
648        let action_err = match attempt_result {
649            Ok(result) => return Ok(result),
650            // Abort is an operator decision, not an attempt failure: no
651            // StepAttemptFailed is recorded, the run terminates here.
652            Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
653            Err(action_err) => action_err,
654        };
655
656        // Conservative re-executability gate: a step that already performed
657        // work on the world this attempt is not safely re-runnable.
658        let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
659        let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
660        let exec_err = action_error_to_execution(action_err, &step.id);
661        reporter.fact(StepFact::StepAttemptFailed {
662            step: step.id.clone(),
663            attempt,
664            error: exec_err.to_error_record(),
665        })?;
666
667        let policy_allows = match step.retry {
668            RetryPolicy::Never => false,
669            RetryPolicy::MaxAttempts(max) => attempt < max,
670            RetryPolicy::Prompt => true,
671        };
672
673        if retriable && policy_allows {
674            // Default `false`: the headless/dry-run driver answers the default,
675            // so non-interactive runs abort rather than loop on a deterministic
676            // error.
677            let answer = reporter.prompt(&Prompt::Confirm {
678                question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
679                default: Some(false),
680            })?;
681            if matches!(answer, Response::Bool(true)) {
682                attempt = attempt.saturating_add(1);
683                continue;
684            }
685        }
686        return Err(exec_err);
687    }
688}
689
690/// Map a handler-level [`ActionError`] to the execution-level error the run
691/// terminates with. `Aborted` is included for completeness; the executor
692/// special-cases it before calling this so it never records a step-attempt
693/// failure for an abort.
694fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
695    match err {
696        ActionError::Aborted => ExecutionError::Aborted,
697        ActionError::Disconnected => ExecutionError::StepFailed {
698            step: step_id.clone(),
699            reason: "frontend channel disconnected".to_string(),
700        },
701        ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
702        ActionError::Backend(e) => ExecutionError::BackendError(e),
703        ActionError::Failed(reason) => ExecutionError::StepFailed {
704            step: step_id.clone(),
705            reason,
706        },
707    }
708}
709
710impl ExecutionError {
711    /// Convert this execution error into a transcript-friendly record.
712    ///
713    /// The audit `class` derives from the error's nature; for a backend error
714    /// it reuses the same retriability judgment the runtime uses for retry
715    /// (a retriable backend error is environmental).
716    fn to_error_record(&self) -> ErrorRecord {
717        let (class, kind) = match self {
718            ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
719            ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
720            ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
721            ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
722            ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
723            ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
724            ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
725            ExecutionError::MaterialLoadFailed { .. } => {
726                (ErrorClass::Integrity, "material_load_failed")
727            }
728            ExecutionError::OutputWriteFailed { .. } => {
729                (ErrorClass::Integrity, "output_write_failed")
730            }
731            ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
732            ExecutionError::BackendError(e) => {
733                let class = if e.retriability().is_retriable() {
734                    ErrorClass::Environmental
735                } else {
736                    ErrorClass::Integrity
737                };
738                (class, "backend_error")
739            }
740        };
741        ErrorRecord::new(class, kind, self.to_string())
742    }
743}
744
745fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
746    let missing: Vec<_> = ceremony
747        .parameters
748        .iter()
749        .filter(|(_, p)| p.value.is_null())
750        .map(|(id, _)| id.as_str().to_string())
751        .collect();
752    if missing.is_empty() {
753        Ok(())
754    } else {
755        Err(ExecutionError::ValidationFailed(format!(
756            "Parameters missing values: {}",
757            missing.join(", ")
758        )))
759    }
760}
761
762/// Fold a step's produced artifacts into the running [`ExecutionState`].
763fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
764    let mut artifacts = state.artifacts;
765    for (id, value) in result.artifacts {
766        artifacts.insert(id, value);
767    }
768    ExecutionState {
769        params: state.params,
770        roles: state.roles,
771        materials: state.materials,
772        dry_run: state.dry_run,
773        artifacts,
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use crossbeam_channel::unbounded;
780    use rite_model::ActionType;
781
782    use super::*;
783    use crate::actions::ActionCategory;
784    use crate::transcript_sink::InMemorySink;
785
786    #[test]
787    fn action_error_retriability_delegates_to_backend() {
788        assert_eq!(
789            ActionError::Backend(BackendError::TokenNotPresent).retriability(),
790            Retriability::Transient,
791        );
792        assert_eq!(
793            ActionError::Backend(BackendError::PinBlocked).retriability(),
794            Retriability::Fatal,
795        );
796    }
797
798    #[test]
799    fn handler_level_errors_are_fatal() {
800        assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
801        assert_eq!(
802            ActionError::Disconnected.retriability(),
803            Retriability::Fatal
804        );
805        assert_eq!(
806            ActionError::Failed("check_value mismatch".into()).retriability(),
807            Retriability::Fatal,
808        );
809    }
810
811    struct PingAction;
812
813    impl Action for PingAction {
814        fn metadata(&self) -> ActionMetadata {
815            ActionMetadata {
816                action_type: ActionType::Attest,
817                description: "test action that always succeeds",
818                category: ActionCategory::Verification,
819            }
820        }
821
822        fn execute(
823            &self,
824            _step: &StepInfo,
825            _ctx: &HandlerContext,
826            _params: &serde_json::Value,
827            reporter: &mut Reporter<'_>,
828            _backend: Option<&mut dyn Backend>,
829        ) -> Result<StepResult, ActionError> {
830            reporter.log(Icon::Info, "ping")?;
831            Ok(StepResult::completed("pinged".to_string()))
832        }
833    }
834
835    fn minimal_ceremony() -> Ceremony {
836        let yaml = r#"
837version: "0.2"
838name: "Test"
839roles:
840  participant:
841    person: "Alice"
842sections:
843  main:
844    role: ${role.participant}
845    steps:
846      ping:
847        action: attest
848        silent: true
849        with:
850          statement: "I confirm."
851"#;
852        rite_resolver::resolve(yaml, None)
853            .into_result()
854            .expect("resolve")
855    }
856
857    fn dry_run_output_config() -> OutputConfig {
858        OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
859    }
860
861    #[test]
862    fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
863        let ceremony = minimal_ceremony();
864        let mut registry = ActionRegistry::new();
865        registry.register(Arc::new(PingAction));
866
867        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
868        let (event_tx, event_rx) = unbounded::<ExecEvent>();
869        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
870
871        let executor = Executor::new(
872            ceremony,
873            registry,
874            BackendRegistry::new(),
875            dry_run_output_config(),
876            true,
877            StartupSnapshot::placeholder(),
878        );
879
880        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
881
882        // Drain events. With silent=true and dry_run=true, no prompts fire.
883        let mut facts = Vec::new();
884        while let Ok(ev) = event_rx.recv() {
885            if let ExecEvent::Fact { fact, .. } = ev {
886                facts.push(fact);
887            }
888        }
889
890        let summary = join.join().expect("executor join").expect("run ok");
891        assert_eq!(summary.steps_completed, 1);
892
893        let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
894        assert_eq!(
895            kinds,
896            vec![
897                "ceremony_started",
898                "entropy_seeded",
899                "step_started",
900                "step_completed",
901                "ceremony_completed",
902            ],
903        );
904
905        drop(cmd_tx);
906    }
907
908    #[test]
909    fn run_stamps_every_fact_from_the_injected_clock() {
910        use crate::test_support::{FixedClock, fixed_test_time};
911
912        let mut registry = ActionRegistry::new();
913        registry.register(Arc::new(PingAction));
914
915        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
916        let (event_tx, event_rx) = unbounded::<ExecEvent>();
917        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
918
919        let fixed = fixed_test_time();
920        let executor = Executor::new(
921            minimal_ceremony(),
922            registry,
923            BackendRegistry::new(),
924            dry_run_output_config(),
925            true,
926            StartupSnapshot::placeholder(),
927        )
928        .with_clock(Arc::new(FixedClock(fixed)));
929
930        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
931
932        let mut times = Vec::new();
933        while let Ok(ev) = event_rx.recv() {
934            if let ExecEvent::Fact { at, .. } = ev {
935                times.push(at);
936            }
937        }
938        join.join().expect("executor join").expect("run ok");
939
940        // Every fact carries the injected time, including the terminal
941        // `ceremony_completed`, which the runner emits outside `reporter.fact`.
942        assert!(!times.is_empty());
943        assert!(times.iter().all(|&t| t == fixed));
944
945        drop(cmd_tx);
946    }
947
948    #[derive(Debug, PartialEq, Eq)]
949    enum PacingTrace {
950        CeremonyStarted,
951        StepStarted(String),
952        ContinuePrompt { step: Option<String> },
953        StepCompleted,
954        CeremonyCompleted,
955    }
956
957    /// Two-step ceremony with pacing enabled: each step's body must be
958    /// gated on a `Continue` prompt that the executor emits **between**
959    /// `StepStarted` and the handler. After the last step there must be
960    /// no trailing pause, only `StepCompleted` and `CeremonyCompleted`.
961    #[test]
962    #[allow(clippy::too_many_lines)]
963    fn pacing_prompts_fire_at_step_start_not_step_end() {
964        let yaml = r#"
965version: "0.2"
966name: "Pacing"
967roles:
968  participant:
969    person: "Alice"
970sections:
971  main:
972    role: ${role.participant}
973    steps:
974      first:
975        action: attest
976        with:
977          statement: "first"
978      second:
979        action: attest
980        with:
981          statement: "second"
982"#;
983        let ceremony = rite_resolver::resolve(yaml, None)
984            .into_result()
985            .expect("resolve");
986        let mut registry = ActionRegistry::new();
987        registry.register(Arc::new(PingAction));
988
989        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
990        let (event_tx, event_rx) = unbounded::<ExecEvent>();
991        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
992
993        let executor = Executor::new(
994            ceremony,
995            registry,
996            BackendRegistry::new(),
997            dry_run_output_config(),
998            false, // dry_run=false so prompts actually fire
999            StartupSnapshot::placeholder(),
1000        );
1001
1002        // Drive the frontend on a worker so the executor's prompt waits
1003        // don't deadlock. Acknowledge every Continue that arrives and
1004        // collect a trace of the events the executor emitted.
1005        let frontend = std::thread::spawn({
1006            let cmd_tx = cmd_tx.clone();
1007            move || {
1008                let mut trace = Vec::new();
1009                while let Ok(event) = event_rx.recv() {
1010                    match event {
1011                        ExecEvent::Fact {
1012                            fact: StepFact::CeremonyStarted { .. },
1013                            ..
1014                        } => {
1015                            trace.push(PacingTrace::CeremonyStarted);
1016                        }
1017                        ExecEvent::Fact {
1018                            fact: StepFact::StepStarted { label, .. },
1019                            ..
1020                        } => {
1021                            trace.push(PacingTrace::StepStarted(label));
1022                        }
1023                        ExecEvent::Fact {
1024                            fact: StepFact::StepCompleted { .. },
1025                            ..
1026                        } => {
1027                            trace.push(PacingTrace::StepCompleted);
1028                        }
1029                        ExecEvent::Fact {
1030                            fact: StepFact::CeremonyCompleted { .. },
1031                            ..
1032                        } => {
1033                            trace.push(PacingTrace::CeremonyCompleted);
1034                        }
1035                        ExecEvent::AwaitPrompt {
1036                            prompt_id,
1037                            prompt: Prompt::Continue { .. },
1038                            step,
1039                            ..
1040                        } => {
1041                            trace.push(PacingTrace::ContinuePrompt {
1042                                step: step.map(|s| s.as_str().to_string()),
1043                            });
1044                            let _ = cmd_tx.send(UiCommand::PromptResponse {
1045                                prompt_id,
1046                                response: crate::protocol::Response::Acknowledge,
1047                            });
1048                        }
1049                        _ => {}
1050                    }
1051                }
1052                trace
1053            }
1054        });
1055
1056        let summary = executor
1057            .run(&cmd_rx, &event_tx, sink)
1058            .expect("ceremony runs");
1059        assert_eq!(summary.steps_completed, 2);
1060        // Drop the local senders so the frontend thread sees the
1061        // event channel close and returns.
1062        drop(event_tx);
1063        drop(cmd_tx);
1064        let trace = frontend.join().expect("frontend join");
1065
1066        // Required order:
1067        //   CeremonyStarted, Continue{None},
1068        //   StepStarted(1), Continue{Some(1)}, StepCompleted,
1069        //   StepStarted(2), Continue{Some(2)}, StepCompleted,
1070        //   CeremonyCompleted
1071        let mut iter = trace.iter();
1072        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1073        assert_eq!(
1074            iter.next(),
1075            Some(&PacingTrace::ContinuePrompt { step: None })
1076        );
1077        let first_step = match iter.next() {
1078            Some(PacingTrace::StepStarted(label)) => label.clone(),
1079            other => panic!("expected first StepStarted, got {other:?}"),
1080        };
1081        assert_eq!(
1082            iter.next(),
1083            Some(&PacingTrace::ContinuePrompt {
1084                step: Some("first".to_string()),
1085            }),
1086            "first step's Continue prompt must fire after StepStarted and before the body",
1087        );
1088        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1089        let second_step = match iter.next() {
1090            Some(PacingTrace::StepStarted(label)) => label.clone(),
1091            other => panic!("expected second StepStarted, got {other:?}"),
1092        };
1093        assert_eq!(
1094            iter.next(),
1095            Some(&PacingTrace::ContinuePrompt {
1096                step: Some("second".to_string()),
1097            }),
1098        );
1099        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1100        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1101        assert_eq!(iter.next(), None, "no further events expected");
1102        // Steps are auto-numbered when there's only one section.
1103        assert_eq!(first_step, "1");
1104        assert_eq!(second_step, "2");
1105    }
1106
1107    #[test]
1108    fn aborts_when_action_returns_aborted() {
1109        struct AbortAction;
1110        impl Action for AbortAction {
1111            fn metadata(&self) -> ActionMetadata {
1112                ActionMetadata {
1113                    action_type: ActionType::Attest,
1114                    description: "aborts immediately",
1115                    category: ActionCategory::Verification,
1116                }
1117            }
1118            fn execute(
1119                &self,
1120                _step: &StepInfo,
1121                _ctx: &HandlerContext,
1122                _params: &serde_json::Value,
1123                _reporter: &mut Reporter<'_>,
1124                _backend: Option<&mut dyn Backend>,
1125            ) -> Result<StepResult, ActionError> {
1126                Err(ActionError::Aborted)
1127            }
1128        }
1129
1130        let ceremony = minimal_ceremony();
1131        let mut registry = ActionRegistry::new();
1132        registry.register(Arc::new(AbortAction));
1133
1134        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1135        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1136        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1137
1138        let executor = Executor::new(
1139            ceremony,
1140            registry,
1141            BackendRegistry::new(),
1142            dry_run_output_config(),
1143            true,
1144            StartupSnapshot::placeholder(),
1145        );
1146
1147        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1148
1149        let mut got_failed = false;
1150        while let Ok(ev) = event_rx.recv() {
1151            if let ExecEvent::Fact {
1152                fact: StepFact::CeremonyFailed { error, .. },
1153                ..
1154            } = ev
1155            {
1156                assert_eq!(error.kind, "aborted");
1157                // An abort is classified distinctly from a failure, and its
1158                // message is ceremony-level (no empty step-id quotes).
1159                assert_eq!(error.class, ErrorClass::Abort);
1160                assert_eq!(error.message, "Ceremony aborted by the operator");
1161                got_failed = true;
1162            }
1163        }
1164        assert!(got_failed, "expected CeremonyFailed event");
1165
1166        let result = join.join().expect("executor join");
1167        assert!(matches!(result, Err(ExecutionError::Aborted)));
1168    }
1169
1170    // ---- Retry loop tests ----
1171
1172    /// What [`FlakyAction`] does at the start of every attempt (failing or
1173    /// not, mirroring an action that always prompts before its backend call),
1174    /// to exercise the re-executability gate.
1175    enum BeforeFail {
1176        Nothing,
1177        /// Emit a side-effect fact (a backend operation): closes the gate.
1178        SideEffect,
1179        /// Issue a secret prompt (like a PIN entry): the answered prompt is
1180        /// recorded but must not close the gate.
1181        Prompt,
1182        /// Record an operator deviation note: recorded but must not close
1183        /// the gate.
1184        Deviation,
1185    }
1186
1187    /// Test action whose first `fails_remaining` executions fail with
1188    /// `error()`, then it succeeds.
1189    struct FlakyAction {
1190        fails_remaining: std::sync::Mutex<u32>,
1191        error: fn() -> ActionError,
1192        before_fail: BeforeFail,
1193    }
1194
1195    impl FlakyAction {
1196        fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1197            Arc::new(Self {
1198                fails_remaining: std::sync::Mutex::new(fails),
1199                error,
1200                before_fail,
1201            })
1202        }
1203    }
1204
1205    impl Action for FlakyAction {
1206        fn metadata(&self) -> ActionMetadata {
1207            ActionMetadata {
1208                action_type: ActionType::Attest,
1209                description: "flaky test action",
1210                category: ActionCategory::Verification,
1211            }
1212        }
1213
1214        fn execute(
1215            &self,
1216            step: &StepInfo,
1217            _ctx: &HandlerContext,
1218            _params: &serde_json::Value,
1219            reporter: &mut Reporter<'_>,
1220            _backend: Option<&mut dyn Backend>,
1221        ) -> Result<StepResult, ActionError> {
1222            let mut remaining = self.fails_remaining.lock().expect("lock");
1223            match self.before_fail {
1224                BeforeFail::Nothing => {}
1225                BeforeFail::SideEffect => {
1226                    reporter.fact(StepFact::BackendOperation {
1227                        step: step.id.clone(),
1228                        kind: "test_op".to_string(),
1229                        inputs: serde_json::Value::Null,
1230                        outputs: serde_json::Value::Null,
1231                        fingerprint: None,
1232                    })?;
1233                }
1234                BeforeFail::Prompt => {
1235                    reporter.prompt(&Prompt::Secret {
1236                        label: "Enter PIN".to_string(),
1237                    })?;
1238                }
1239                BeforeFail::Deviation => {
1240                    reporter.fact(StepFact::DeviationRecorded {
1241                        step: None,
1242                        text: "operator note".to_string(),
1243                    })?;
1244                }
1245            }
1246            if *remaining == 0 {
1247                return Ok(StepResult::completed("done".to_string()));
1248            }
1249            *remaining = remaining.saturating_sub(1);
1250            Err((self.error)())
1251        }
1252    }
1253
1254    fn transient() -> ActionError {
1255        ActionError::Backend(BackendError::TokenNotPresent)
1256    }
1257
1258    fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1259        let yaml = format!(
1260            r#"
1261version: "0.2"
1262name: "Retry"
1263roles:
1264  participant:
1265    person: "Alice"
1266sections:
1267  main:
1268    role: ${{role.participant}}
1269    steps:
1270      work:
1271        action: attest
1272        silent: true
1273        {retry_clause}
1274        with:
1275          statement: "x"
1276"#
1277        );
1278        rite_resolver::resolve(&yaml, None)
1279            .into_result()
1280            .expect("resolve")
1281    }
1282
1283    /// Run `ceremony` with `action`, answering every retry `Confirm` with
1284    /// `confirm_answer`. Returns the run result, the emitted facts, and the
1285    /// number of retry prompts seen.
1286    fn drive(
1287        ceremony: Ceremony,
1288        action: Arc<dyn Action>,
1289        confirm_answer: bool,
1290    ) -> (
1291        Result<ExecutionSummary, ExecutionError>,
1292        Vec<StepFact>,
1293        usize,
1294    ) {
1295        drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1296    }
1297
1298    /// Like [`drive`], with a caller-provided backend registry for steps
1299    /// that name a backend.
1300    fn drive_with_backends(
1301        ceremony: Ceremony,
1302        action: Arc<dyn Action>,
1303        confirm_answer: bool,
1304        backend_registry: BackendRegistry,
1305    ) -> (
1306        Result<ExecutionSummary, ExecutionError>,
1307        Vec<StepFact>,
1308        usize,
1309    ) {
1310        let mut registry = ActionRegistry::new();
1311        registry.register(action);
1312
1313        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1314        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1315        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1316
1317        let executor = Executor::new(
1318            ceremony,
1319            registry,
1320            backend_registry,
1321            dry_run_output_config(),
1322            true, // dry_run: skips pacing/start prompts, leaving only the retry Confirm
1323            StartupSnapshot::placeholder(),
1324        );
1325        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1326
1327        let mut facts = Vec::new();
1328        let mut confirms = 0usize;
1329        while let Ok(ev) = event_rx.recv() {
1330            match ev {
1331                ExecEvent::Fact { fact, .. } => facts.push(fact),
1332                ExecEvent::AwaitPrompt {
1333                    prompt_id,
1334                    prompt: Prompt::Confirm { .. },
1335                    ..
1336                } => {
1337                    confirms = confirms.saturating_add(1);
1338                    cmd_tx
1339                        .send(UiCommand::PromptResponse {
1340                            prompt_id,
1341                            response: Response::Bool(confirm_answer),
1342                        })
1343                        .expect("send confirm");
1344                }
1345                ExecEvent::AwaitPrompt {
1346                    prompt_id,
1347                    prompt: Prompt::Secret { .. },
1348                    ..
1349                } => {
1350                    cmd_tx
1351                        .send(UiCommand::PromptResponse {
1352                            prompt_id,
1353                            response: Response::Secret("123456".to_string().into()),
1354                        })
1355                        .expect("send secret");
1356                }
1357                _ => {}
1358            }
1359        }
1360        let result = join.join().expect("executor join");
1361        (result, facts, confirms)
1362    }
1363
1364    fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1365        facts
1366            .iter()
1367            .filter_map(|f| match f {
1368                StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1369                _ => None,
1370            })
1371            .collect()
1372    }
1373
1374    #[test]
1375    fn transient_failure_is_retried_then_succeeds() {
1376        let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1377        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1378
1379        let summary = result.expect("run ok");
1380        assert_eq!(summary.steps_completed, 1);
1381        assert_eq!(confirms, 1, "one retry prompt");
1382        assert_eq!(attempt_numbers(&facts), vec![1]);
1383        // The recorded attempt is classified environmental (a transient backend error).
1384        let StepFact::StepAttemptFailed { error, .. } = facts
1385            .iter()
1386            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1387            .expect("attempt fact")
1388        else {
1389            unreachable!()
1390        };
1391        assert_eq!(error.class, ErrorClass::Environmental);
1392        assert!(
1393            facts
1394                .iter()
1395                .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1396        );
1397    }
1398
1399    #[test]
1400    fn retry_is_blocked_once_a_side_effect_was_emitted() {
1401        // Transient error, but the attempt already performed a backend
1402        // operation: the conservative gate refuses to retry, so no prompt
1403        // fires.
1404        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1405        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1406
1407        assert!(result.is_err(), "run fails");
1408        assert_eq!(confirms, 0, "gate blocks the retry prompt");
1409        assert_eq!(attempt_numbers(&facts), vec![1]);
1410        assert!(
1411            facts
1412                .iter()
1413                .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1414        );
1415    }
1416
1417    #[test]
1418    fn prompting_before_a_transient_failure_stays_retriable() {
1419        // A PIN-style secret prompt answered before the failure records a
1420        // PromptAnswered fact, but answering a prompt is not a side effect:
1421        // the retry must still be offered, and the second attempt re-prompts.
1422        let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1423        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1424
1425        let summary = result.expect("run ok");
1426        assert_eq!(summary.steps_completed, 1);
1427        assert_eq!(confirms, 1, "the retry prompt fired");
1428        assert_eq!(attempt_numbers(&facts), vec![1]);
1429        let secret_answers = facts
1430            .iter()
1431            .filter(|f| {
1432                matches!(
1433                    f,
1434                    StepFact::PromptAnswered {
1435                        prompt: Prompt::Secret { .. },
1436                        ..
1437                    }
1438                )
1439            })
1440            .count();
1441        assert_eq!(secret_answers, 2, "each attempt records its own answer");
1442    }
1443
1444    #[test]
1445    fn a_deviation_note_does_not_block_retry() {
1446        let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1447        let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1448
1449        let summary = result.expect("run ok");
1450        assert_eq!(summary.steps_completed, 1);
1451        assert_eq!(confirms, 1, "the retry prompt fired");
1452    }
1453
1454    #[test]
1455    fn backend_acquisition_failure_is_retriable() {
1456        use std::sync::atomic::{AtomicU32, Ordering};
1457
1458        // Lazy backend initialization fails on the first probe (device
1459        // unplugged) and succeeds on the second: the operator must get the
1460        // same retry offer as for a transient error inside the handler.
1461        struct NullBackend {
1462            name: String,
1463        }
1464        impl Backend for NullBackend {
1465            fn name(&self) -> &str {
1466                &self.name
1467            }
1468            fn provider(&self) -> &'static str {
1469                "mock"
1470            }
1471            fn fingerprint(&self) -> String {
1472                format!("mock={}", self.name)
1473            }
1474        }
1475
1476        let probes = Arc::new(AtomicU32::new(0));
1477        let probes_in_factory = Arc::clone(&probes);
1478        let mut backends =
1479            crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1480                if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1481                    Err(BackendError::HardwareFailure("no device".to_string()))
1482                } else {
1483                    Ok(Box::new(NullBackend {
1484                        name: "token".to_string(),
1485                    }) as Box<dyn Backend>)
1486                }
1487            }));
1488        backends.declare(
1489            "token".to_string(),
1490            rite_sdk::BackendConfig {
1491                provider: "mock".to_string(),
1492                extra: serde_json::json!({}),
1493            },
1494        );
1495
1496        let yaml = r#"
1497version: "0.2"
1498name: "Retry"
1499backends:
1500  token:
1501    provider: mock
1502roles:
1503  participant:
1504    person: "Alice"
1505sections:
1506  main:
1507    role: ${role.participant}
1508    steps:
1509      work:
1510        action: attest
1511        backend: token
1512        silent: true
1513        with:
1514          statement: "x"
1515"#;
1516        let ceremony = rite_resolver::resolve(yaml, None)
1517            .into_result()
1518            .expect("resolve");
1519
1520        let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1521        let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1522
1523        let summary = result.expect("run ok after the operator retried");
1524        assert_eq!(summary.steps_completed, 1);
1525        assert_eq!(confirms, 1, "the retry prompt fired");
1526        assert_eq!(attempt_numbers(&facts), vec![1]);
1527        let StepFact::StepAttemptFailed { error, .. } = facts
1528            .iter()
1529            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1530            .expect("attempt fact")
1531        else {
1532            unreachable!()
1533        };
1534        assert_eq!(error.class, ErrorClass::Environmental);
1535        assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1536    }
1537
1538    #[test]
1539    fn retry_never_fails_immediately() {
1540        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1541        let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1542
1543        assert!(result.is_err());
1544        assert_eq!(confirms, 0, "retry: never never prompts");
1545        assert_eq!(attempt_numbers(&facts), vec![1]);
1546    }
1547
1548    #[test]
1549    fn retry_attempts_caps_total_tries() {
1550        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1551        let (result, facts, confirms) =
1552            drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1553
1554        assert!(result.is_err());
1555        // Attempt 1 fails and prompts; attempt 2 fails and the cap forbids a
1556        // further prompt.
1557        assert_eq!(confirms, 1);
1558        assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1559    }
1560
1561    #[test]
1562    fn declining_the_retry_prompt_terminates() {
1563        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1564        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1565
1566        assert!(result.is_err());
1567        assert_eq!(confirms, 1, "prompted once, operator declined");
1568        assert_eq!(attempt_numbers(&facts), vec![1]);
1569    }
1570
1571    fn fact_kind(fact: &StepFact) -> &'static str {
1572        match fact {
1573            StepFact::CeremonyStarted { .. } => "ceremony_started",
1574            StepFact::ActStarted { .. } => "act_started",
1575            StepFact::StepStarted { .. } => "step_started",
1576            StepFact::PromptAnswered { .. } => "prompt_answered",
1577            StepFact::BackendOperation { .. } => "backend_operation",
1578            StepFact::AttestationRecorded { .. } => "attestation_recorded",
1579            StepFact::ArtifactWritten { .. } => "artifact_written",
1580            StepFact::DeviationRecorded { .. } => "deviation_recorded",
1581            StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
1582            StepFact::StepCompleted { .. } => "step_completed",
1583            StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1584            StepFact::CeremonyFailed { .. } => "ceremony_failed",
1585            StepFact::EntropySeeded { .. } => "entropy_seeded",
1586            StepFact::EntropyContributed { .. } => "entropy_contributed",
1587            StepFact::EntropyDrawn { .. } => "entropy_drawn",
1588            _ => "unknown",
1589        }
1590    }
1591}