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