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, HashSet};
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rite_model::{
39    ActId, ActionType, ArtifactId, Ceremony, Level, MaterialId, MaterialKind, MaterialSource,
40    OutputId, ParamId, RoleId, Sha256Digest, Step, TranscriptHeader,
41};
42use rite_sdk::{Backend, BackendError, Retriability};
43use thiserror::Error;
44
45use crate::actions::ArtifactValue;
46use crate::backend::BackendRegistry;
47use crate::clock::{Clock, SystemClock};
48use crate::entropy::DERIVATION_V1;
49use crate::executor::{
50    ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
51};
52use crate::expressions;
53use crate::output_config::OutputConfig;
54use crate::protocol::{ExecEvent, MaterialOverview, Response, UiCommand, UiSignal};
55use crate::reporter::{Reporter, ReporterError};
56use crate::state::{ExecutionState, HandlerContext, StepResult};
57use crate::step_info::StepInfo;
58use crate::system_info::StartupSnapshot;
59use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
60use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
61
62/// Gather the machine entropy `m` that seeds the ceremony entropy source.
63///
64/// Sourced directly from the host OS RNG, independent of any
65/// ceremony backend, so a device the ceremony later challenges cannot
66/// influence its own challenge nonce. A dry run instead returns a fixed,
67/// clearly-labelled sentinel so a re-derived value can never be mistaken for
68/// one produced under real entropy.
69fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
70    if dry_run {
71        let mut m = [0u8; 32];
72        for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
73            *slot = *byte;
74        }
75        return Ok((m, "dry-run".to_string()));
76    }
77    let m =
78        crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
79    Ok((m, "os".to_string()))
80}
81
82/// Name and version of the program writing transcripts, as the header
83/// records it.
84pub const PRODUCER: &str = concat!("rite ", env!("CARGO_PKG_VERSION"));
85
86/// A fresh run identifier: 16 bytes from the OS RNG, as lowercase hex.
87fn new_run_id() -> Result<String, ExecutionError> {
88    let id: [u8; 16] =
89        crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
90    Ok(base16ct::lower::encode_string(&id))
91}
92
93/// Errors that may surface from an [`Action`] handler.
94#[derive(Debug, Error)]
95pub enum ActionError {
96    /// Operator requested abort. Propagated from [`Reporter::check_abort`]
97    /// or [`Reporter::prompt`].
98    #[error("ceremony aborted by operator")]
99    Aborted,
100    /// Frontend channel disconnected. The executor unwinds to a failure.
101    #[error("frontend channel disconnected")]
102    Disconnected,
103    /// Transcript sink failed to persist a fact.
104    #[error("transcript write failed: {0}")]
105    Transcript(std::io::Error),
106    /// Underlying backend returned an error.
107    #[error("backend error: {0}")]
108    Backend(#[from] BackendError),
109    /// Catch-all for handler-specific failures.
110    #[error("{0}")]
111    Failed(String),
112}
113
114impl From<ReporterError> for ActionError {
115    fn from(value: ReporterError) -> Self {
116        match value {
117            ReporterError::Aborted => ActionError::Aborted,
118            ReporterError::Disconnected => ActionError::Disconnected,
119            ReporterError::Transcript(e) => ActionError::Transcript(e),
120            ReporterError::NoCurrentStep(_)
121            | ReporterError::DuplicateDraw { .. }
122            | ReporterError::Unseeded
123            | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
124        }
125    }
126}
127
128impl ActionError {
129    /// Classify whether the step that failed with this error may be retried.
130    ///
131    /// Delegates to [`BackendError::retriability`] for [`ActionError::Backend`];
132    /// every other handler-level error is [`Retriability::Fatal`]. `Failed`
133    /// covers procedural failures (a `check_value` mismatch must never be
134    /// re-run, that would be tampering) as well as integrity and configuration
135    /// errors, all of which are non-retriable. `Aborted` is an operator decision
136    /// handled on the abort path and is never auto-retried, so it is classified
137    /// fatal here as a safe default.
138    #[must_use]
139    pub fn retriability(&self) -> Retriability {
140        match self {
141            ActionError::Backend(e) => e.retriability(),
142            ActionError::Aborted
143            | ActionError::Disconnected
144            | ActionError::Transcript(_)
145            | ActionError::Failed(_) => Retriability::Fatal,
146        }
147    }
148}
149
150/// Deserialize the per-step parameter blob into a typed handler-specific struct.
151///
152/// Wraps `serde_json::from_value` and converts the error into
153/// [`ActionError::Failed`] with a consistent "invalid params: …" wording.
154///
155/// # Errors
156///
157/// Returns [`ActionError::Failed`] if `params` cannot be deserialized into `P`.
158pub fn parse_params<P: serde::de::DeserializeOwned>(
159    params: &serde_json::Value,
160) -> Result<P, ActionError> {
161    serde_json::from_value(params.clone())
162        .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
163}
164
165impl From<ReporterError> for ExecutionError {
166    fn from(value: ReporterError) -> Self {
167        match value {
168            ReporterError::Aborted => ExecutionError::Aborted,
169            ReporterError::Disconnected => {
170                ExecutionError::TranscriptError("frontend channel disconnected".to_string())
171            }
172            ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
173            ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
174            ReporterError::DuplicateDraw { .. }
175            | ReporterError::Unseeded
176            | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
177        }
178    }
179}
180
181/// Action trait, every ceremony step handler implements this.
182///
183/// Handlers emit facts and prompts through [`Reporter`] and return a
184/// [`StepResult`]. Transcript evidence is whatever facts the handler
185/// emits via [`Reporter::fact`] during execution; there is no separate
186/// evidence struct.
187pub trait Action: Send + Sync {
188    /// The action this handler implements, as the DSL names it.
189    fn action_type(&self) -> ActionType;
190
191    /// Apply per-step parameter defaults before validation.
192    fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
193
194    /// Report values in a step's `with:` block that *this build* cannot carry
195    /// out, without executing the step.
196    ///
197    /// Only build-relative findings belong here. Whether a value is one the
198    /// action accepts at all is settled by
199    /// [`rite_model::params::check`], which the resolver runs, so that an
200    /// editor linking no backend reports it too. What is left for a handler is
201    /// the question only a linked binary can answer: whether the feature,
202    /// backend or library that would carry the value out is present.
203    ///
204    /// `params` holds the literal part of the block, as produced by
205    /// [`literal_expr_value`](rite_model::expression::literal_expr_value): a
206    /// field whose value is an unevaluated expression is absent, because its
207    /// value belongs to run time. Never report a field as missing.
208    fn unsupported_params(&self, _params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
209        Vec::new()
210    }
211
212    /// Execute the action.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`ActionError`] when the handler cannot complete, when the
217    /// operator aborts, or when the frontend disappears.
218    fn execute(
219        &self,
220        step: &StepInfo,
221        ctx: &HandlerContext,
222        params: &serde_json::Value,
223        reporter: &mut Reporter<'_>,
224        backend: Option<&mut dyn Backend>,
225    ) -> Result<StepResult, ActionError>;
226}
227
228/// A `with:` value this build cannot carry out, and the step holding it.
229///
230/// The same distinction [`ActionRegistry::unsupported_actions`] rests on: a
231/// definition is validated on one machine and executed on another, so a
232/// finding that depends on the running binary must not condemn the document.
233/// `rite check` warns; `rite run` refuses, because that process is the one
234/// that would have to do the work.
235#[derive(Debug, Clone)]
236pub struct StepUnsupportedParam {
237    /// The step whose `with:` block holds the value.
238    pub step: rite_model::StepId,
239    /// What this build cannot do, phrased for the ceremony author.
240    pub message: String,
241}
242
243/// Registry mapping action types to their handlers.
244#[derive(Default)]
245pub struct ActionRegistry {
246    actions: HashMap<ActionType, Arc<dyn Action>>,
247}
248
249impl ActionRegistry {
250    /// Create an empty registry.
251    #[must_use]
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Register an action handler.
257    pub fn register(&mut self, action: Arc<dyn Action>) {
258        let action_type = action.action_type();
259        self.actions.insert(action_type, action);
260    }
261
262    /// Look up the handler for an action type.
263    #[must_use]
264    pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
265        self.actions.get(action)
266    }
267
268    /// Iterate over registered action types.
269    pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
270        self.actions.keys()
271    }
272
273    /// Return action types used in the execution plan that have no
274    /// registered handler. Results are deduplicated and returned in
275    /// first-occurrence order.
276    #[must_use]
277    pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
278        let mut seen = std::collections::HashSet::new();
279        let mut result = Vec::new();
280        for step in steps {
281            if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
282                result.push(step.action);
283            }
284        }
285        result
286    }
287
288    /// Ask each step's handler what this build cannot carry out.
289    ///
290    /// Handlers see the literal part of the block with defaults applied, the
291    /// same order the executor uses. A step whose action has no handler in
292    /// this build is skipped: [`unsupported_actions`](Self::unsupported_actions)
293    /// already reports it, and there is nobody to ask. A step whose whole
294    /// `with:` block is an expression is skipped too, since none of it is
295    /// known yet.
296    #[must_use]
297    pub fn unsupported_step_params(&self, steps: &[rite_model::Step]) -> Vec<StepUnsupportedParam> {
298        let mut found = Vec::new();
299        for step in steps {
300            let Some(handler) = self.actions.get(&step.action) else {
301                continue;
302            };
303            let Some(mut params) = rite_model::expression::literal_expr_value(&step.with) else {
304                continue;
305            };
306            let step_info = crate::executor::step_info_from(step);
307            handler.apply_defaults(&mut params, &step_info);
308            found.extend(
309                handler
310                    .unsupported_params(&params, &step_info)
311                    .into_iter()
312                    .map(|message| StepUnsupportedParam {
313                        step: step.id.clone(),
314                        message,
315                    }),
316            );
317        }
318        found
319    }
320}
321
322/// Summary returned by [`Executor::run`] on a successful completion.
323#[derive(Debug)]
324pub struct ExecutionSummary {
325    /// Ceremony name from the DSL.
326    pub ceremony_name: String,
327    /// Number of steps that completed successfully.
328    pub steps_completed: usize,
329    /// Final transcript fingerprint.
330    pub transcript_fingerprint: TranscriptFingerprint,
331}
332
333/// Channel-driven executor. One per ceremony run.
334pub struct Executor {
335    ceremony: Ceremony,
336    registry: ActionRegistry,
337    backend_registry: BackendRegistry,
338    output_config: OutputConfig,
339    dry_run: bool,
340    startup: StartupSnapshot,
341    clock: Arc<dyn Clock>,
342}
343
344impl Executor {
345    /// Build an executor for a single ceremony run.
346    ///
347    /// `startup` carries the system identity and device environment the CLI
348    /// gathered at launch; the executor echoes them to the frontend as UI
349    /// signals at ceremony start and does not otherwise inspect them.
350    #[must_use]
351    pub fn new(
352        ceremony: Ceremony,
353        registry: ActionRegistry,
354        backend_registry: BackendRegistry,
355        output_config: OutputConfig,
356        dry_run: bool,
357        startup: StartupSnapshot,
358    ) -> Self {
359        Self {
360            ceremony,
361            registry,
362            backend_registry,
363            output_config,
364            dry_run,
365            startup,
366            clock: Arc::new(SystemClock),
367        }
368    }
369
370    /// Override the run clock. The default is [`SystemClock`]; tests inject a
371    /// fixed or stepping clock to make recorded event times deterministic.
372    #[must_use]
373    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
374        self.clock = clock;
375        self
376    }
377
378    /// Drive the ceremony to completion.
379    ///
380    /// Blocking. Intended to be invoked on a dedicated executor thread
381    /// spawned by the frontend. The `transcript_sink` is finalized before
382    /// this function returns, and its fingerprint is included in the
383    /// returned summary.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`ExecutionError`] for any condition that prevents the
388    /// ceremony from completing, validation failures, missing actions,
389    /// abort, backend errors, or transcript I/O failures.
390    pub fn run(
391        self,
392        cmd_rx: &Receiver<UiCommand>,
393        event_tx: &Sender<ExecEvent>,
394        mut transcript_sink: Box<dyn TranscriptSink>,
395    ) -> Result<ExecutionSummary, ExecutionError> {
396        validate_parameters(&self.ceremony)?;
397
398        // The header goes first: it says what the file is before any fact.
399        let header = TranscriptHeader::new(PRODUCER, &new_run_id()?, self.dry_run);
400        transcript_sink
401            .begin(&header)
402            .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
403
404        let resolved_params: HashMap<ParamId, serde_json::Value> = self
405            .ceremony
406            .parameters
407            .iter()
408            .map(|(id, p)| (id.clone(), p.value.clone()))
409            .collect();
410
411        let roles: HashMap<RoleId, String> = self
412            .ceremony
413            .roles
414            .iter()
415            .map(|(id, r)| (id.clone(), r.name.clone()))
416            .collect();
417
418        let materials_map: HashMap<MaterialId, String> = self
419            .ceremony
420            .materials
421            .iter()
422            .map(|(id, m)| (id.clone(), m.display_name().to_string()))
423            .collect();
424
425        let ceremony_name = self.ceremony.metadata.name.clone();
426        // Keep a clock handle for the terminal facts below: `execute_inner`
427        // consumes `self`, so the reporter and the runner share this clone.
428        let clock = Arc::clone(&self.clock);
429        let mut reporter = Reporter::new(
430            event_tx,
431            cmd_rx,
432            transcript_sink.as_mut(),
433            Arc::clone(&clock),
434        );
435
436        let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
437
438        // Record the terminal fact first so it ends up on disk. Then
439        // finalize the sink (which only returns the chain head, no
440        // sidecar is written) and forward both the fact and the
441        // fingerprint to the UI.
442        match outcome {
443            Ok(counts) => {
444                let at = clock.now();
445                let completed = StepFact::CeremonyCompleted {};
446                transcript_sink
447                    .record(at, completed.default_level(), &completed)
448                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
449                let _ = event_tx.send(ExecEvent::Fact {
450                    at,
451                    fact: completed,
452                });
453                let fingerprint = transcript_sink
454                    .finalize()
455                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
456                let _ = event_tx.send(ExecEvent::Finalized {
457                    fingerprint: fingerprint.as_str().to_string(),
458                });
459                Ok(ExecutionSummary {
460                    ceremony_name,
461                    steps_completed: counts.completed,
462                    transcript_fingerprint: fingerprint,
463                })
464            }
465            Err(err) => {
466                let at = clock.now();
467                let record = err.to_error_record();
468                let failed = StepFact::CeremonyFailed { error: record };
469                let _ = transcript_sink.record(at, failed.default_level(), &failed);
470                let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
471                if let Ok(fingerprint) = transcript_sink.finalize() {
472                    let _ = event_tx.send(ExecEvent::Finalized {
473                        fingerprint: fingerprint.as_str().to_string(),
474                    });
475                }
476                Err(err)
477            }
478        }
479    }
480
481    #[allow(clippy::too_many_lines)]
482    fn execute_inner(
483        mut self,
484        reporter: &mut Reporter<'_>,
485        resolved_params: HashMap<ParamId, serde_json::Value>,
486        roles: HashMap<RoleId, String>,
487        materials_map: HashMap<MaterialId, String>,
488    ) -> Result<StepCounts, ExecutionError> {
489        let dry_run = self.dry_run;
490
491        reporter.fact(StepFact::CeremonyStarted {
492            name: self.ceremony.metadata.name.clone(),
493            template: self.ceremony.source_digest.clone(),
494        })?;
495
496        // Establish the ceremony entropy source before any step can draw from
497        // it. This machine seed is run-metadata (the runner-emitted exception
498        // to "facts come from actions"); human contributions, by contrast, are
499        // authored `gather_entropy` steps that fold into the ratchet later.
500        let (m, source) = gather_machine_entropy(dry_run)?;
501        reporter.seed_entropy(&m);
502        reporter.fact(StepFact::EntropySeeded {
503            m: base16ct::lower::encode_string(&m),
504            source,
505            derivation: DERIVATION_V1.to_string(),
506        })?;
507
508        // The inputs this run was given, one fact each so each can be
509        // disclosed on its own. The template digest on `CeremonyStarted`
510        // covers none of them.
511        for (id, role) in self.ceremony.roles.iter() {
512            reporter.fact(StepFact::RoleDeclared {
513                role: id.clone(),
514                name: role.name.clone(),
515            })?;
516        }
517        for (id, role) in self.ceremony.roles.iter() {
518            if let Some(person) = &role.person {
519                reporter.fact(StepFact::RoleAssigned {
520                    role: id.clone(),
521                    person: person.clone(),
522                })?;
523            }
524        }
525        for (id, parameter) in self.ceremony.parameters.iter() {
526            reporter.fact(StepFact::ParameterBound {
527                name: id.clone(),
528                value: parameter.value.clone(),
529            })?;
530        }
531
532        // Pre-ceremony overview: descriptive metadata for the UI's
533        // Overview screen. Sent as a UI-only signal, not a transcript
534        // fact, because the YAML is the source of truth for these fields
535        // and the run record shouldn't duplicate them.
536        reporter.signal(UiSignal::CeremonyOverview {
537            description: self.ceremony.metadata.description.clone(),
538            materials: self
539                .ceremony
540                .materials
541                .iter()
542                .map(|(_, m)| MaterialOverview::from_material(m))
543                .collect(),
544            step_count: self.ceremony.execution_plan.len(),
545        })?;
546
547        // Machine identity and device environment for the System tab. Both
548        // UI-only: identity that belongs in the transcript is recorded by the
549        // `machine_info` action, not fed in here. `Environment` is emitted
550        // once today but shaped to be re-emitted by a future live observer.
551        reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
552        reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
553
554        // Ceremony-start gate: let the operator review the overview
555        // before any side effect (material loading, first step body). The
556        // prompt is emitted with no current step set, so it carries
557        // `step: None` through to the frontend.
558        if !dry_run {
559            reporter.prompt(&Prompt::Continue {
560                hint: Some("Press Enter to start the ceremony".to_string()),
561            })?;
562        }
563
564        let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
565
566        // Load materials. The digest of a file is its own fact, so an
567        // audience can learn that an input was loaded without being able to
568        // test guesses against its content.
569        for (id, material) in self.ceremony.materials.iter() {
570            let artifact = load_material_artifact(id.as_str(), material)?;
571            let identifier = match &material.kind {
572                MaterialKind::Physical { identifier, .. } => identifier.clone(),
573                MaterialKind::Digital {
574                    source: Some(MaterialSource::Identifier { identifier }),
575                } => Some(identifier.clone()),
576                MaterialKind::Digital { .. } => None,
577            };
578            // A file loads as bytes; an identifier or physical item as text.
579            let digest = match &artifact {
580                ArtifactValue::Bytes(bytes) => Some(Sha256Digest::of(bytes)),
581                _ => None,
582            };
583            reporter.fact(StepFact::MaterialLoaded {
584                name: id.clone(),
585                identifier,
586            })?;
587            if let Some(digest) = digest {
588                reporter.fact(StepFact::MaterialDigest {
589                    name: id.clone(),
590                    digest,
591                })?;
592            }
593            state = state.with_material(ArtifactId::new(id.as_str()), artifact);
594        }
595
596        let mut backends = RunBackends {
597            registry: &mut self.backend_registry,
598            bound: HashSet::new(),
599        };
600        let has_acts = !self.ceremony.acts.is_empty();
601        let mut current_act: Option<ActId> = None;
602        let mut counts = StepCounts::default();
603
604        // Move the plan out so we can iterate it while still borrowing
605        // other fields of `self.ceremony` (sections, acts, outputs).
606        let plan = std::mem::take(&mut self.ceremony.execution_plan);
607        for step in &plan {
608            // Act boundary
609            if has_acts {
610                let step_act = self
611                    .ceremony
612                    .sections
613                    .get(&step.section)
614                    .and_then(|s| s.act.clone());
615                if step_act != current_act {
616                    if let Some(act_id) = &step_act
617                        && let Some(act) = self.ceremony.acts.get(act_id)
618                    {
619                        reporter.fact(StepFact::ActStarted {
620                            id: act_id.clone(),
621                            label: act
622                                .name
623                                .clone()
624                                .unwrap_or_else(|| act_id.as_str().to_string()),
625                        })?;
626                    }
627                    current_act = step_act;
628                }
629            }
630
631            reporter.set_current_step(Some(step.id.clone()));
632            reporter.set_current_backend(step.backend.clone());
633            reporter.fact(StepFact::StepStarted {
634                id: step.id.clone(),
635                label: step.step_label.clone(),
636                role: step.role.clone().unwrap_or_else(|| RoleId::new("")),
637            })?;
638
639            // Pacing: gate the step body on operator acknowledgement. The
640            // prompt fires with the step header already visible, so the
641            // operator confirms "ready for step X" rather than "what just
642            // happened was fine". `silent` skips it for auto-advancing
643            // bookkeeping steps; `dry_run` skips it for non-interactive
644            // verification.
645            if !step.silent && !dry_run {
646                reporter.prompt(&Prompt::Continue {
647                    hint: Some(format!("Press Enter to start step {}", step.step_label)),
648                })?;
649            }
650
651            // Look up handler
652            let handler = self
653                .registry
654                .get(&step.action)
655                .ok_or(ExecutionError::UnknownAction(step.action))?
656                .clone();
657
658            let ctx = state.handler_context();
659            let step_info = step_info_from(step);
660            let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
661            handler.apply_defaults(&mut params, &step_info);
662
663            let result = execute_step_with_retry(
664                handler.as_ref(),
665                step,
666                &step_info,
667                &ctx,
668                &params,
669                reporter,
670                &mut backends,
671            )?;
672
673            // StepCompleted
674            reporter.fact(StepFact::StepCompleted {
675                id: step.id.clone(),
676                outcome: result.outcome.clone(),
677            })?;
678
679            if let StepOutcome::Completed { .. } = &result.outcome {
680                counts.completed = counts.completed.saturating_add(1);
681            }
682
683            state = merge_step_result(state, result);
684
685            // Write output-bound artifacts. The DSL declares which artifact
686            // a step `creates`; we promote that to disk if it is also a
687            // ceremony-level output.
688            if let Some(artifact_id) = &step.creates
689                && !dry_run
690            {
691                let output_id = OutputId::new(artifact_id.as_str());
692                if self.ceremony.outputs.contains(&output_id) {
693                    std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
694                        ExecutionError::OutputWriteFailed {
695                            name: "artifacts directory".to_string(),
696                            reason: e.to_string(),
697                        }
698                    })?;
699
700                    let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
701                        ExecutionError::OutputWriteFailed {
702                            name: artifact_id.as_str().to_string(),
703                            reason: "artifact not produced".to_string(),
704                        }
705                    })?;
706
707                    let (path, digest, _size, _mime_type) =
708                        write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
709
710                    // Only the file name is recorded: the file lives under
711                    // the run's `artifacts/` directory, and the operator's
712                    // filesystem layout stays out of the transcript.
713                    let file = path
714                        .file_name()
715                        .and_then(|name| name.to_str())
716                        .ok_or_else(|| ExecutionError::OutputWriteFailed {
717                            name: artifact_id.as_str().to_string(),
718                            reason: format!("'{}' has no UTF-8 file name", path.display()),
719                        })?
720                        .to_string();
721
722                    // Opened content gets no digest: a digest of a secret can
723                    // be tested against guesses.
724                    let digest = match artifact_value {
725                        ArtifactValue::Secret(_) => None,
726                        _ => Some(digest),
727                    };
728
729                    reporter.fact_at(
730                        artifact_level(step.action, artifact_value),
731                        StepFact::ArtifactWritten {
732                            step: step.id.clone(),
733                            name: artifact_id.as_str().to_string(),
734                            file,
735                            digest,
736                        },
737                    )?;
738                }
739            }
740        }
741
742        reporter.set_current_step(None);
743        reporter.set_current_backend(None);
744        drop(plan);
745
746        Ok(counts)
747    }
748}
749
750#[derive(Default, Debug, Clone, Copy)]
751struct StepCounts {
752    completed: usize,
753}
754
755/// The level an artifact is recorded at, which decides whether a disclosure
756/// carries its file.
757///
758/// Certificates and public keys exist to be distributed. Bytes are public
759/// only from the actions that make distributable bytes, a CSR or a signature;
760/// from any other step they may be a document the ceremony handled, so they
761/// stay restricted. Wrapped keys and encrypted content are ciphertext: not
762/// secret, and not for everyone either. Opened content is confidential and is
763/// never bundled at all.
764fn artifact_level(action: ActionType, value: &ArtifactValue) -> Level {
765    match value {
766        ArtifactValue::Certificate(_) | ArtifactValue::PublicKey(_) => Level::PUBLIC,
767        ArtifactValue::Bytes(_)
768            if matches!(
769                action,
770                ActionType::GenerateCsr | ActionType::SignData | ActionType::PivSign
771            ) =>
772        {
773            Level::PUBLIC
774        }
775        ArtifactValue::Bytes(_)
776        | ArtifactValue::Text(_)
777        | ArtifactValue::WrappedKey(_)
778        | ArtifactValue::EncryptedData(_)
779        | ArtifactValue::BackendKey { .. }
780        | ArtifactValue::Shares(_) => Level::RESTRICTED,
781        ArtifactValue::Secret(_) => Level::CONFIDENTIAL,
782    }
783}
784
785/// The run's backends, and which of them have had their identity recorded.
786struct RunBackends<'a> {
787    registry: &'a mut BackendRegistry,
788    bound: HashSet<String>,
789}
790
791impl RunBackends<'_> {
792    /// Hand out a backend, recording its identity the first time it is
793    /// acquired. Operations name the backend and do not repeat the identity,
794    /// which can carry device serials.
795    fn acquire(
796        &mut self,
797        name: &str,
798        reporter: &mut Reporter<'_>,
799    ) -> Result<&mut dyn Backend, ActionError> {
800        let backend = self.registry.get_mut(name)?;
801        if self.bound.insert(name.to_string()) {
802            reporter.fact(StepFact::BackendBound {
803                name: name.to_string(),
804                provider: backend.provider().to_string(),
805                identity: backend.fingerprint(),
806            })?;
807        }
808        Ok(backend)
809    }
810}
811
812/// Run one step, retrying transient failures under operator control.
813///
814/// Each attempt re-acquires the backend handle (it is moved into the handler)
815/// and snapshots the side-effect count beforehand: a transient error is
816/// retriable only if the attempt performed no work on the world yet (the
817/// conservative re-executability gate). Interaction records such as an
818/// answered prompt do not close the gate: a retried attempt simply prompts
819/// again. A retriable error within the step's [`RetryPolicy`] prompts the
820/// operator; any other failure terminates the run.
821fn execute_step_with_retry(
822    handler: &dyn Action,
823    step: &Step,
824    step_info: &StepInfo,
825    ctx: &HandlerContext,
826    params: &serde_json::Value,
827    reporter: &mut Reporter<'_>,
828    backends: &mut RunBackends<'_>,
829) -> Result<StepResult, ExecutionError> {
830    let mut attempt: u32 = 1;
831    loop {
832        let side_effects_before = reporter.side_effects_emitted();
833
834        // Backend acquisition is part of the attempt: a failed lazy
835        // initialization (device unplugged, daemon down) carries the same
836        // retriability semantics as a backend error inside the handler.
837        let attempt_result = match &step_info.backend {
838            Some(name) => backends.acquire(name, reporter).and_then(|backend| {
839                handler.execute(step_info, ctx, params, reporter, Some(backend))
840            }),
841            None => handler.execute(step_info, ctx, params, reporter, None),
842        };
843
844        let action_err = match attempt_result {
845            Ok(result) => return Ok(result),
846            // Abort is an operator decision, not an attempt failure: no
847            // StepAttemptFailed is recorded, the run terminates here.
848            Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
849            Err(action_err) => action_err,
850        };
851
852        // Conservative re-executability gate: a step that already performed
853        // work on the world this attempt is not safely re-runnable.
854        let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
855        let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
856        let exec_err = action_error_to_execution(action_err, &step.id);
857        reporter.fact(StepFact::StepAttemptFailed {
858            step: step.id.clone(),
859            attempt,
860            error: exec_err.to_error_record(),
861        })?;
862
863        let policy_allows = match step.retry {
864            RetryPolicy::Never => false,
865            RetryPolicy::MaxAttempts(max) => attempt < max,
866            RetryPolicy::Prompt => true,
867        };
868
869        if retriable && policy_allows {
870            // Default `false`: the headless/dry-run driver answers the default,
871            // so non-interactive runs abort rather than loop on a deterministic
872            // error.
873            let answer = reporter.prompt(&Prompt::Confirm {
874                question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
875                default: Some(false),
876            })?;
877            if matches!(answer, Response::Bool(true)) {
878                attempt = attempt.saturating_add(1);
879                continue;
880            }
881        }
882        return Err(exec_err);
883    }
884}
885
886/// Map a handler-level [`ActionError`] to the execution-level error the run
887/// terminates with. `Aborted` is included for completeness; the executor
888/// special-cases it before calling this so it never records a step-attempt
889/// failure for an abort.
890fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
891    match err {
892        ActionError::Aborted => ExecutionError::Aborted,
893        ActionError::Disconnected => ExecutionError::StepFailed {
894            step: step_id.clone(),
895            reason: "frontend channel disconnected".to_string(),
896        },
897        ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
898        ActionError::Backend(e) => ExecutionError::BackendError(e),
899        ActionError::Failed(reason) => ExecutionError::StepFailed {
900            step: step_id.clone(),
901            reason,
902        },
903    }
904}
905
906impl ExecutionError {
907    /// Convert this execution error into a transcript-friendly record.
908    ///
909    /// The audit `class` derives from the error's nature; for a backend error
910    /// it reuses the same retriability judgment the runtime uses for retry
911    /// (a retriable backend error is environmental).
912    fn to_error_record(&self) -> ErrorRecord {
913        let (class, kind) = match self {
914            ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
915            ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
916            ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
917            ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
918            ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
919            ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
920            ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
921            ExecutionError::MaterialLoadFailed { .. } => {
922                (ErrorClass::Integrity, "material_load_failed")
923            }
924            ExecutionError::OutputWriteFailed { .. } => {
925                (ErrorClass::Integrity, "output_write_failed")
926            }
927            ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
928            ExecutionError::BackendError(e) => {
929                let class = if e.retriability().is_retriable() {
930                    ErrorClass::Environmental
931                } else {
932                    ErrorClass::Integrity
933                };
934                (class, "backend_error")
935            }
936        };
937        ErrorRecord::new(class, kind, self.to_string())
938    }
939}
940
941fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
942    let missing: Vec<_> = ceremony
943        .parameters
944        .iter()
945        .filter(|(_, p)| p.value.is_null())
946        .map(|(id, _)| id.as_str().to_string())
947        .collect();
948    if missing.is_empty() {
949        Ok(())
950    } else {
951        Err(ExecutionError::ValidationFailed(format!(
952            "Parameters missing values: {}",
953            missing.join(", ")
954        )))
955    }
956}
957
958/// Fold a step's produced artifacts into the running [`ExecutionState`].
959fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
960    let mut artifacts = state.artifacts;
961    for (id, value) in result.artifacts {
962        artifacts.insert(id, value);
963    }
964    ExecutionState {
965        params: state.params,
966        roles: state.roles,
967        materials: state.materials,
968        dry_run: state.dry_run,
969        artifacts,
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use crossbeam_channel::unbounded;
976    use rite_model::ActionType;
977
978    use super::*;
979    use crate::transcript_sink::InMemorySink;
980
981    #[test]
982    fn action_error_retriability_delegates_to_backend() {
983        assert_eq!(
984            ActionError::Backend(BackendError::TokenNotPresent).retriability(),
985            Retriability::Transient,
986        );
987        assert_eq!(
988            ActionError::Backend(BackendError::PinBlocked).retriability(),
989            Retriability::Fatal,
990        );
991    }
992
993    #[test]
994    fn handler_level_errors_are_fatal() {
995        assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
996        assert_eq!(
997            ActionError::Disconnected.retriability(),
998            Retriability::Fatal
999        );
1000        assert_eq!(
1001            ActionError::Failed("check_value mismatch".into()).retriability(),
1002            Retriability::Fatal,
1003        );
1004    }
1005
1006    struct PingAction;
1007
1008    impl Action for PingAction {
1009        fn action_type(&self) -> ActionType {
1010            ActionType::Attest
1011        }
1012
1013        fn execute(
1014            &self,
1015            _step: &StepInfo,
1016            _ctx: &HandlerContext,
1017            _params: &serde_json::Value,
1018            reporter: &mut Reporter<'_>,
1019            _backend: Option<&mut dyn Backend>,
1020        ) -> Result<StepResult, ActionError> {
1021            reporter.log(crate::protocol::Icon::Info, "ping")?;
1022            Ok(StepResult::completed("pinged".to_string()))
1023        }
1024    }
1025
1026    fn minimal_ceremony() -> Ceremony {
1027        let yaml = r#"
1028version: "0.3"
1029name: "Test"
1030roles:
1031  participant:
1032    person: "Alice"
1033sections:
1034  main:
1035    role: ${role.participant}
1036    steps:
1037      ping:
1038        action: attest
1039        silent: true
1040        with:
1041          statement: "I confirm."
1042"#;
1043        rite_resolver::resolve(yaml, None)
1044            .into_result()
1045            .expect("resolve")
1046    }
1047
1048    /// Reports that this build has no puce, so a test can watch a
1049    /// build-relative finding travel through the registry.
1050    struct PickyAction;
1051
1052    impl Action for PickyAction {
1053        fn action_type(&self) -> ActionType {
1054            ActionType::Attest
1055        }
1056
1057        fn unsupported_params(&self, params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
1058            match params.get("statement").and_then(serde_json::Value::as_str) {
1059                Some("puce") => vec!["this build has no puce".to_string()],
1060                _ => Vec::new(),
1061            }
1062        }
1063
1064        fn execute(
1065            &self,
1066            _step: &StepInfo,
1067            _ctx: &HandlerContext,
1068            _params: &serde_json::Value,
1069            _reporter: &mut Reporter<'_>,
1070            _backend: Option<&mut dyn Backend>,
1071        ) -> Result<StepResult, ActionError> {
1072            Ok(StepResult::completed("done".to_string()))
1073        }
1074    }
1075
1076    fn ceremony_with_statement(statement: &str) -> Ceremony {
1077        let yaml = format!(
1078            r#"
1079version: "0.3"
1080name: "Test"
1081roles:
1082  participant:
1083    person: "Alice"
1084sections:
1085  main:
1086    role: ${{role.participant}}
1087    steps:
1088      ping:
1089        action: attest
1090        with:
1091          statement: "{statement}"
1092"#
1093        );
1094        rite_resolver::resolve(&yaml, None)
1095            .into_result()
1096            .expect("resolve")
1097    }
1098
1099    #[test]
1100    fn reports_what_this_build_cannot_carry_out() {
1101        let ceremony = ceremony_with_statement("puce");
1102        let mut registry = ActionRegistry::new();
1103        registry.register(Arc::new(PickyAction));
1104
1105        let found = registry.unsupported_step_params(&ceremony.execution_plan);
1106        let [issue] = found.as_slice() else {
1107            panic!("expected exactly one finding, got {found:?}");
1108        };
1109        assert_eq!(issue.step.as_str(), "ping");
1110        assert_eq!(issue.message, "this build has no puce");
1111    }
1112
1113    #[test]
1114    fn accepts_a_block_the_handler_is_happy_with() {
1115        let ceremony = ceremony_with_statement("I confirm.");
1116        let mut registry = ActionRegistry::new();
1117        registry.register(Arc::new(PickyAction));
1118
1119        assert!(
1120            registry
1121                .unsupported_step_params(&ceremony.execution_plan)
1122                .is_empty()
1123        );
1124    }
1125
1126    #[test]
1127    fn skips_an_action_this_build_has_no_handler_for() {
1128        // `unsupported_actions` is what reports these; asking a handler that
1129        // does not exist would be a second, misleading error on one step.
1130        let ceremony = ceremony_with_statement("puce");
1131        let registry = ActionRegistry::new();
1132
1133        assert!(
1134            registry
1135                .unsupported_step_params(&ceremony.execution_plan)
1136                .is_empty()
1137        );
1138    }
1139
1140    fn dry_run_output_config() -> OutputConfig {
1141        OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
1142    }
1143
1144    #[test]
1145    fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
1146        let ceremony = minimal_ceremony();
1147        let mut registry = ActionRegistry::new();
1148        registry.register(Arc::new(PingAction));
1149
1150        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1151        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1152        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1153
1154        let executor = Executor::new(
1155            ceremony,
1156            registry,
1157            BackendRegistry::new(),
1158            dry_run_output_config(),
1159            true,
1160            StartupSnapshot::placeholder(),
1161        );
1162
1163        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1164
1165        // Drain events. With silent=true and dry_run=true, no prompts fire.
1166        let mut facts = Vec::new();
1167        while let Ok(ev) = event_rx.recv() {
1168            if let ExecEvent::Fact { fact, .. } = ev {
1169                facts.push(fact);
1170            }
1171        }
1172
1173        let summary = join.join().expect("executor join").expect("run ok");
1174        assert_eq!(summary.steps_completed, 1);
1175
1176        let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
1177        assert_eq!(
1178            kinds,
1179            vec![
1180                "ceremony_started",
1181                "entropy_seeded",
1182                "role_declared",
1183                "role_assigned",
1184                "step_started",
1185                "step_completed",
1186                "ceremony_completed",
1187            ],
1188        );
1189
1190        drop(cmd_tx);
1191    }
1192
1193    /// Produces opened content, so a test can watch it reach the run
1194    /// directory through the same path as any other artifact.
1195    struct OpensContent;
1196
1197    impl Action for OpensContent {
1198        fn action_type(&self) -> ActionType {
1199            ActionType::Attest
1200        }
1201
1202        fn execute(
1203            &self,
1204            step: &StepInfo,
1205            _ctx: &HandlerContext,
1206            _params: &serde_json::Value,
1207            _reporter: &mut Reporter<'_>,
1208            _backend: Option<&mut dyn Backend>,
1209        ) -> Result<StepResult, ActionError> {
1210            let produces = step.produces.clone().expect("step creates");
1211            Ok(StepResult::completed_with_artifact(
1212                "opened",
1213                produces,
1214                crate::actions::ArtifactValue::Secret(secrecy::SecretBox::new(Box::new(
1215                    b"the recovery phrase".to_vec(),
1216                ))),
1217            ))
1218        }
1219    }
1220
1221    /// A ceremony whose one step creates `opened`, declared as an output.
1222    fn ceremony_writing_opened_content() -> Ceremony {
1223        let yaml = r#"
1224version: "0.3"
1225name: "Test"
1226roles:
1227  participant:
1228    person: "Alice"
1229output:
1230  opened:
1231    type: document
1232sections:
1233  main:
1234    role: ${role.participant}
1235    steps:
1236      open:
1237        action: attest
1238        silent: true
1239        with:
1240          statement: "I confirm."
1241        creates: opened
1242"#;
1243        rite_resolver::resolve(yaml, None)
1244            .into_result()
1245            .expect("resolve")
1246    }
1247
1248    /// Run `ceremony` to completion, acknowledging every prompt, and return
1249    /// the run's result with the facts it emitted.
1250    fn run_acknowledging_prompts(
1251        ceremony: Ceremony,
1252        output_config: OutputConfig,
1253    ) -> (Result<ExecutionSummary, ExecutionError>, Vec<StepFact>) {
1254        let mut registry = ActionRegistry::new();
1255        registry.register(Arc::new(OpensContent));
1256
1257        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1258        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1259        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1260
1261        let executor = Executor::new(
1262            ceremony,
1263            registry,
1264            BackendRegistry::new(),
1265            output_config,
1266            false,
1267            StartupSnapshot::placeholder(),
1268        );
1269
1270        let frontend = std::thread::spawn({
1271            let cmd_tx = cmd_tx.clone();
1272            move || {
1273                let mut facts = Vec::new();
1274                while let Ok(event) = event_rx.recv() {
1275                    match event {
1276                        ExecEvent::Fact { fact, .. } => facts.push(fact),
1277                        ExecEvent::AwaitPrompt { prompt_id, .. } => {
1278                            let _ = cmd_tx.send(UiCommand::PromptResponse {
1279                                prompt_id,
1280                                response: crate::protocol::Response::Acknowledge,
1281                            });
1282                        }
1283                        _ => {}
1284                    }
1285                }
1286                facts
1287            }
1288        });
1289
1290        let result = executor.run(&cmd_rx, &event_tx, sink);
1291        drop(event_tx);
1292        drop(cmd_tx);
1293        let facts = frontend.join().expect("frontend join");
1294        (result, facts)
1295    }
1296
1297    #[test]
1298    fn opened_content_declared_as_an_output_is_written() {
1299        let dir = tempfile::tempdir().expect("tempdir");
1300        let config = OutputConfig::new(dir.path().to_path_buf());
1301        let (result, facts) = run_acknowledging_prompts(ceremony_writing_opened_content(), config);
1302
1303        let summary = result.expect("ceremony runs");
1304        assert_eq!(summary.steps_completed, 1);
1305        let written = facts.iter().find_map(|f| match f {
1306            StepFact::ArtifactWritten {
1307                name, file, digest, ..
1308            } => Some((name.clone(), file.clone(), digest.clone())),
1309            _ => None,
1310        });
1311        let (name, file, digest) = written.expect("the artifact is written");
1312        assert_eq!(name, "opened");
1313        // Opened content: no digest of a secret goes into the record.
1314        assert_eq!(digest, None);
1315        assert_eq!(
1316            std::fs::read(dir.path().join("artifacts").join(file)).expect("read artifact"),
1317            b"the recovery phrase"
1318        );
1319    }
1320
1321    #[test]
1322    fn run_stamps_every_fact_from_the_injected_clock() {
1323        use crate::test_support::{FixedClock, fixed_test_time};
1324
1325        let mut registry = ActionRegistry::new();
1326        registry.register(Arc::new(PingAction));
1327
1328        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1329        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1330        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1331
1332        let fixed = fixed_test_time();
1333        let executor = Executor::new(
1334            minimal_ceremony(),
1335            registry,
1336            BackendRegistry::new(),
1337            dry_run_output_config(),
1338            true,
1339            StartupSnapshot::placeholder(),
1340        )
1341        .with_clock(Arc::new(FixedClock(fixed)));
1342
1343        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1344
1345        let mut times = Vec::new();
1346        while let Ok(ev) = event_rx.recv() {
1347            if let ExecEvent::Fact { at, .. } = ev {
1348                times.push(at);
1349            }
1350        }
1351        join.join().expect("executor join").expect("run ok");
1352
1353        // Every fact carries the injected time, including the terminal
1354        // `ceremony_completed`, which the runner emits outside `reporter.fact`.
1355        assert!(!times.is_empty());
1356        assert!(times.iter().all(|&t| t == fixed));
1357
1358        drop(cmd_tx);
1359    }
1360
1361    #[derive(Debug, PartialEq, Eq)]
1362    enum PacingTrace {
1363        CeremonyStarted,
1364        StepStarted(String),
1365        ContinuePrompt { step: Option<String> },
1366        StepCompleted,
1367        CeremonyCompleted,
1368    }
1369
1370    /// Two-step ceremony with pacing enabled: each step's body must be
1371    /// gated on a `Continue` prompt that the executor emits **between**
1372    /// `StepStarted` and the handler. After the last step there must be
1373    /// no trailing pause, only `StepCompleted` and `CeremonyCompleted`.
1374    #[test]
1375    #[allow(clippy::too_many_lines)]
1376    fn pacing_prompts_fire_at_step_start_not_step_end() {
1377        let yaml = r#"
1378version: "0.3"
1379name: "Pacing"
1380roles:
1381  participant:
1382    person: "Alice"
1383sections:
1384  main:
1385    role: ${role.participant}
1386    steps:
1387      first:
1388        action: attest
1389        with:
1390          statement: "first"
1391      second:
1392        action: attest
1393        with:
1394          statement: "second"
1395"#;
1396        let ceremony = rite_resolver::resolve(yaml, None)
1397            .into_result()
1398            .expect("resolve");
1399        let mut registry = ActionRegistry::new();
1400        registry.register(Arc::new(PingAction));
1401
1402        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1403        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1404        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1405
1406        let executor = Executor::new(
1407            ceremony,
1408            registry,
1409            BackendRegistry::new(),
1410            dry_run_output_config(),
1411            false, // dry_run=false so prompts actually fire
1412            StartupSnapshot::placeholder(),
1413        );
1414
1415        // Drive the frontend on a worker so the executor's prompt waits
1416        // don't deadlock. Acknowledge every Continue that arrives and
1417        // collect a trace of the events the executor emitted.
1418        let frontend = std::thread::spawn({
1419            let cmd_tx = cmd_tx.clone();
1420            move || {
1421                let mut trace = Vec::new();
1422                while let Ok(event) = event_rx.recv() {
1423                    match event {
1424                        ExecEvent::Fact {
1425                            fact: StepFact::CeremonyStarted { .. },
1426                            ..
1427                        } => {
1428                            trace.push(PacingTrace::CeremonyStarted);
1429                        }
1430                        ExecEvent::Fact {
1431                            fact: StepFact::StepStarted { label, .. },
1432                            ..
1433                        } => {
1434                            trace.push(PacingTrace::StepStarted(label));
1435                        }
1436                        ExecEvent::Fact {
1437                            fact: StepFact::StepCompleted { .. },
1438                            ..
1439                        } => {
1440                            trace.push(PacingTrace::StepCompleted);
1441                        }
1442                        ExecEvent::Fact {
1443                            fact: StepFact::CeremonyCompleted { .. },
1444                            ..
1445                        } => {
1446                            trace.push(PacingTrace::CeremonyCompleted);
1447                        }
1448                        ExecEvent::AwaitPrompt {
1449                            prompt_id,
1450                            prompt: Prompt::Continue { .. },
1451                            step,
1452                            ..
1453                        } => {
1454                            trace.push(PacingTrace::ContinuePrompt {
1455                                step: step.map(|s| s.as_str().to_string()),
1456                            });
1457                            let _ = cmd_tx.send(UiCommand::PromptResponse {
1458                                prompt_id,
1459                                response: crate::protocol::Response::Acknowledge,
1460                            });
1461                        }
1462                        _ => {}
1463                    }
1464                }
1465                trace
1466            }
1467        });
1468
1469        let summary = executor
1470            .run(&cmd_rx, &event_tx, sink)
1471            .expect("ceremony runs");
1472        assert_eq!(summary.steps_completed, 2);
1473        // Drop the local senders so the frontend thread sees the
1474        // event channel close and returns.
1475        drop(event_tx);
1476        drop(cmd_tx);
1477        let trace = frontend.join().expect("frontend join");
1478
1479        // Required order:
1480        //   CeremonyStarted, Continue{None},
1481        //   StepStarted(1), Continue{Some(1)}, StepCompleted,
1482        //   StepStarted(2), Continue{Some(2)}, StepCompleted,
1483        //   CeremonyCompleted
1484        let mut iter = trace.iter();
1485        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1486        assert_eq!(
1487            iter.next(),
1488            Some(&PacingTrace::ContinuePrompt { step: None })
1489        );
1490        let first_step = match iter.next() {
1491            Some(PacingTrace::StepStarted(label)) => label.clone(),
1492            other => panic!("expected first StepStarted, got {other:?}"),
1493        };
1494        assert_eq!(
1495            iter.next(),
1496            Some(&PacingTrace::ContinuePrompt {
1497                step: Some("first".to_string()),
1498            }),
1499            "first step's Continue prompt must fire after StepStarted and before the body",
1500        );
1501        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1502        let second_step = match iter.next() {
1503            Some(PacingTrace::StepStarted(label)) => label.clone(),
1504            other => panic!("expected second StepStarted, got {other:?}"),
1505        };
1506        assert_eq!(
1507            iter.next(),
1508            Some(&PacingTrace::ContinuePrompt {
1509                step: Some("second".to_string()),
1510            }),
1511        );
1512        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1513        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1514        assert_eq!(iter.next(), None, "no further events expected");
1515        // Steps are auto-numbered when there's only one section.
1516        assert_eq!(first_step, "1");
1517        assert_eq!(second_step, "2");
1518    }
1519
1520    #[test]
1521    fn aborts_when_action_returns_aborted() {
1522        struct AbortAction;
1523        impl Action for AbortAction {
1524            fn action_type(&self) -> ActionType {
1525                ActionType::Attest
1526            }
1527            fn execute(
1528                &self,
1529                _step: &StepInfo,
1530                _ctx: &HandlerContext,
1531                _params: &serde_json::Value,
1532                _reporter: &mut Reporter<'_>,
1533                _backend: Option<&mut dyn Backend>,
1534            ) -> Result<StepResult, ActionError> {
1535                Err(ActionError::Aborted)
1536            }
1537        }
1538
1539        let ceremony = minimal_ceremony();
1540        let mut registry = ActionRegistry::new();
1541        registry.register(Arc::new(AbortAction));
1542
1543        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1544        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1545        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1546
1547        let executor = Executor::new(
1548            ceremony,
1549            registry,
1550            BackendRegistry::new(),
1551            dry_run_output_config(),
1552            true,
1553            StartupSnapshot::placeholder(),
1554        );
1555
1556        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1557
1558        let mut got_failed = false;
1559        while let Ok(ev) = event_rx.recv() {
1560            if let ExecEvent::Fact {
1561                fact: StepFact::CeremonyFailed { error, .. },
1562                ..
1563            } = ev
1564            {
1565                assert_eq!(error.kind, "aborted");
1566                // An abort is classified distinctly from a failure, and its
1567                // message is ceremony-level (no empty step-id quotes).
1568                assert_eq!(error.class, ErrorClass::Abort);
1569                assert_eq!(error.message, "Ceremony aborted by the operator");
1570                got_failed = true;
1571            }
1572        }
1573        assert!(got_failed, "expected CeremonyFailed event");
1574
1575        let result = join.join().expect("executor join");
1576        assert!(matches!(result, Err(ExecutionError::Aborted)));
1577    }
1578
1579    // ---- Retry loop tests ----
1580
1581    /// What [`FlakyAction`] does at the start of every attempt (failing or
1582    /// not, mirroring an action that always prompts before its backend call),
1583    /// to exercise the re-executability gate.
1584    enum BeforeFail {
1585        Nothing,
1586        /// Emit a side-effect fact (a backend operation): closes the gate.
1587        SideEffect,
1588        /// Issue a secret prompt (like a PIN entry): the answered prompt is
1589        /// recorded but must not close the gate.
1590        Prompt,
1591        /// Record an operator deviation note: recorded but must not close
1592        /// the gate.
1593        Deviation,
1594    }
1595
1596    /// Test action whose first `fails_remaining` executions fail with
1597    /// `error()`, then it succeeds.
1598    struct FlakyAction {
1599        fails_remaining: std::sync::Mutex<u32>,
1600        error: fn() -> ActionError,
1601        before_fail: BeforeFail,
1602    }
1603
1604    impl FlakyAction {
1605        fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1606            Arc::new(Self {
1607                fails_remaining: std::sync::Mutex::new(fails),
1608                error,
1609                before_fail,
1610            })
1611        }
1612    }
1613
1614    impl Action for FlakyAction {
1615        fn action_type(&self) -> ActionType {
1616            ActionType::Attest
1617        }
1618
1619        fn execute(
1620            &self,
1621            _step: &StepInfo,
1622            _ctx: &HandlerContext,
1623            _params: &serde_json::Value,
1624            reporter: &mut Reporter<'_>,
1625            _backend: Option<&mut dyn Backend>,
1626        ) -> Result<StepResult, ActionError> {
1627            let mut remaining = self.fails_remaining.lock().expect("lock");
1628            match self.before_fail {
1629                BeforeFail::Nothing => {}
1630                BeforeFail::SideEffect => {
1631                    reporter.backend_operation(
1632                        "test_op",
1633                        serde_json::Value::Null,
1634                        serde_json::Value::Null,
1635                        None,
1636                    )?;
1637                }
1638                BeforeFail::Prompt => {
1639                    reporter.prompt(&Prompt::Secret {
1640                        label: "Enter PIN".to_string(),
1641                        validator: rite_model::ValidatorSpec::NonEmpty,
1642                    })?;
1643                }
1644                BeforeFail::Deviation => {
1645                    reporter.fact(StepFact::DeviationRecorded {
1646                        step: None,
1647                        text: "operator note".to_string(),
1648                    })?;
1649                }
1650            }
1651            if *remaining == 0 {
1652                return Ok(StepResult::completed("done".to_string()));
1653            }
1654            *remaining = remaining.saturating_sub(1);
1655            Err((self.error)())
1656        }
1657    }
1658
1659    fn transient() -> ActionError {
1660        ActionError::Backend(BackendError::TokenNotPresent)
1661    }
1662
1663    fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1664        let yaml = format!(
1665            r#"
1666version: "0.3"
1667name: "Retry"
1668roles:
1669  participant:
1670    person: "Alice"
1671sections:
1672  main:
1673    role: ${{role.participant}}
1674    steps:
1675      work:
1676        action: attest
1677        silent: true
1678        {retry_clause}
1679        with:
1680          statement: "x"
1681"#
1682        );
1683        rite_resolver::resolve(&yaml, None)
1684            .into_result()
1685            .expect("resolve")
1686    }
1687
1688    /// Run `ceremony` with `action`, answering every retry `Confirm` with
1689    /// `confirm_answer`. Returns the run result, the emitted facts, and the
1690    /// number of retry prompts seen.
1691    fn drive(
1692        ceremony: Ceremony,
1693        action: Arc<dyn Action>,
1694        confirm_answer: bool,
1695    ) -> (
1696        Result<ExecutionSummary, ExecutionError>,
1697        Vec<StepFact>,
1698        usize,
1699    ) {
1700        drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1701    }
1702
1703    /// Like [`drive`], with a caller-provided backend registry for steps
1704    /// that name a backend.
1705    fn drive_with_backends(
1706        ceremony: Ceremony,
1707        action: Arc<dyn Action>,
1708        confirm_answer: bool,
1709        backend_registry: BackendRegistry,
1710    ) -> (
1711        Result<ExecutionSummary, ExecutionError>,
1712        Vec<StepFact>,
1713        usize,
1714    ) {
1715        let mut registry = ActionRegistry::new();
1716        registry.register(action);
1717
1718        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1719        let (event_tx, event_rx) = unbounded::<ExecEvent>();
1720        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1721
1722        let executor = Executor::new(
1723            ceremony,
1724            registry,
1725            backend_registry,
1726            dry_run_output_config(),
1727            true, // dry_run: skips pacing/start prompts, leaving only the retry Confirm
1728            StartupSnapshot::placeholder(),
1729        );
1730        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1731
1732        let mut facts = Vec::new();
1733        let mut confirms = 0usize;
1734        while let Ok(ev) = event_rx.recv() {
1735            match ev {
1736                ExecEvent::Fact { fact, .. } => facts.push(fact),
1737                ExecEvent::AwaitPrompt {
1738                    prompt_id,
1739                    prompt: Prompt::Confirm { .. },
1740                    ..
1741                } => {
1742                    confirms = confirms.saturating_add(1);
1743                    cmd_tx
1744                        .send(UiCommand::PromptResponse {
1745                            prompt_id,
1746                            response: Response::Bool(confirm_answer),
1747                        })
1748                        .expect("send confirm");
1749                }
1750                ExecEvent::AwaitPrompt {
1751                    prompt_id,
1752                    prompt: Prompt::Secret { .. },
1753                    ..
1754                } => {
1755                    cmd_tx
1756                        .send(UiCommand::PromptResponse {
1757                            prompt_id,
1758                            response: Response::Secret("123456".to_string().into()),
1759                        })
1760                        .expect("send secret");
1761                }
1762                _ => {}
1763            }
1764        }
1765        let result = join.join().expect("executor join");
1766        (result, facts, confirms)
1767    }
1768
1769    fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1770        facts
1771            .iter()
1772            .filter_map(|f| match f {
1773                StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1774                _ => None,
1775            })
1776            .collect()
1777    }
1778
1779    #[test]
1780    fn transient_failure_is_retried_then_succeeds() {
1781        let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1782        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1783
1784        let summary = result.expect("run ok");
1785        assert_eq!(summary.steps_completed, 1);
1786        assert_eq!(confirms, 1, "one retry prompt");
1787        assert_eq!(attempt_numbers(&facts), vec![1]);
1788        // The recorded attempt is classified environmental (a transient backend error).
1789        let StepFact::StepAttemptFailed { error, .. } = facts
1790            .iter()
1791            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1792            .expect("attempt fact")
1793        else {
1794            unreachable!()
1795        };
1796        assert_eq!(error.class, ErrorClass::Environmental);
1797        assert!(
1798            facts
1799                .iter()
1800                .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1801        );
1802    }
1803
1804    #[test]
1805    fn retry_is_blocked_once_a_side_effect_was_emitted() {
1806        // Transient error, but the attempt already performed a backend
1807        // operation: the conservative gate refuses to retry, so no prompt
1808        // fires.
1809        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1810        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1811
1812        assert!(result.is_err(), "run fails");
1813        assert_eq!(confirms, 0, "gate blocks the retry prompt");
1814        assert_eq!(attempt_numbers(&facts), vec![1]);
1815        assert!(
1816            facts
1817                .iter()
1818                .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1819        );
1820    }
1821
1822    #[test]
1823    fn prompting_before_a_transient_failure_stays_retriable() {
1824        // A PIN-style secret prompt answered before the failure records a
1825        // PromptAnswered fact, but answering a prompt is not a side effect:
1826        // the retry must still be offered, and the second attempt re-prompts.
1827        let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1828        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1829
1830        let summary = result.expect("run ok");
1831        assert_eq!(summary.steps_completed, 1);
1832        assert_eq!(confirms, 1, "the retry prompt fired");
1833        assert_eq!(attempt_numbers(&facts), vec![1]);
1834        let secret_answers = facts
1835            .iter()
1836            .filter(|f| {
1837                matches!(
1838                    f,
1839                    StepFact::PromptAnswered {
1840                        prompt: Prompt::Secret { .. },
1841                        ..
1842                    }
1843                )
1844            })
1845            .count();
1846        assert_eq!(secret_answers, 2, "each attempt records its own answer");
1847    }
1848
1849    #[test]
1850    fn a_deviation_note_does_not_block_retry() {
1851        let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1852        let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1853
1854        let summary = result.expect("run ok");
1855        assert_eq!(summary.steps_completed, 1);
1856        assert_eq!(confirms, 1, "the retry prompt fired");
1857    }
1858
1859    #[test]
1860    fn backend_acquisition_failure_is_retriable() {
1861        use std::sync::atomic::{AtomicU32, Ordering};
1862
1863        // Lazy backend initialization fails on the first probe (device
1864        // unplugged) and succeeds on the second: the operator must get the
1865        // same retry offer as for a transient error inside the handler.
1866        struct NullBackend {
1867            name: String,
1868        }
1869        impl Backend for NullBackend {
1870            fn name(&self) -> &str {
1871                &self.name
1872            }
1873            fn provider(&self) -> &'static str {
1874                "mock"
1875            }
1876            fn fingerprint(&self) -> String {
1877                format!("mock={}", self.name)
1878            }
1879        }
1880
1881        let probes = Arc::new(AtomicU32::new(0));
1882        let probes_in_factory = Arc::clone(&probes);
1883        let mut backends =
1884            crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1885                if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1886                    Err(BackendError::HardwareFailure("no device".to_string()))
1887                } else {
1888                    Ok(Box::new(NullBackend {
1889                        name: "token".to_string(),
1890                    }) as Box<dyn Backend>)
1891                }
1892            }));
1893        backends.declare(
1894            "token".to_string(),
1895            rite_sdk::BackendConfig {
1896                provider: "mock".to_string(),
1897                extra: serde_json::json!({}),
1898            },
1899        );
1900
1901        let yaml = r#"
1902version: "0.3"
1903name: "Retry"
1904backends:
1905  token:
1906    provider: mock
1907roles:
1908  participant:
1909    person: "Alice"
1910sections:
1911  main:
1912    role: ${role.participant}
1913    steps:
1914      work:
1915        action: attest
1916        backend: token
1917        silent: true
1918        with:
1919          statement: "x"
1920"#;
1921        let ceremony = rite_resolver::resolve(yaml, None)
1922            .into_result()
1923            .expect("resolve");
1924
1925        let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1926        let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1927
1928        let summary = result.expect("run ok after the operator retried");
1929        assert_eq!(summary.steps_completed, 1);
1930        assert_eq!(confirms, 1, "the retry prompt fired");
1931        assert_eq!(attempt_numbers(&facts), vec![1]);
1932        let StepFact::StepAttemptFailed { error, .. } = facts
1933            .iter()
1934            .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1935            .expect("attempt fact")
1936        else {
1937            unreachable!()
1938        };
1939        assert_eq!(error.class, ErrorClass::Environmental);
1940        assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1941    }
1942
1943    #[test]
1944    fn retry_never_fails_immediately() {
1945        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1946        let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1947
1948        assert!(result.is_err());
1949        assert_eq!(confirms, 0, "retry: never never prompts");
1950        assert_eq!(attempt_numbers(&facts), vec![1]);
1951    }
1952
1953    #[test]
1954    fn retry_attempts_caps_total_tries() {
1955        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1956        let (result, facts, confirms) =
1957            drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1958
1959        assert!(result.is_err());
1960        // Attempt 1 fails and prompts; attempt 2 fails and the cap forbids a
1961        // further prompt.
1962        assert_eq!(confirms, 1);
1963        assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1964    }
1965
1966    #[test]
1967    fn declining_the_retry_prompt_terminates() {
1968        let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1969        let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1970
1971        assert!(result.is_err());
1972        assert_eq!(confirms, 1, "prompted once, operator declined");
1973        assert_eq!(attempt_numbers(&facts), vec![1]);
1974    }
1975
1976    #[test]
1977    fn artifact_levels_follow_what_the_artifact_is() {
1978        use secrecy::SecretBox;
1979
1980        let bytes = || ArtifactValue::Bytes(b"x".to_vec());
1981        assert_eq!(
1982            artifact_level(ActionType::SignData, &bytes()),
1983            Level::PUBLIC
1984        );
1985        assert_eq!(
1986            artifact_level(ActionType::GenerateCsr, &bytes()),
1987            Level::PUBLIC
1988        );
1989        // Bytes from any other step may be a document the ceremony handled.
1990        assert_eq!(
1991            artifact_level(ActionType::DecryptData, &bytes()),
1992            Level::RESTRICTED
1993        );
1994        assert_eq!(
1995            artifact_level(
1996                ActionType::DecryptData,
1997                &ArtifactValue::Secret(SecretBox::new(Box::new(b"x".to_vec())))
1998            ),
1999            Level::CONFIDENTIAL
2000        );
2001    }
2002
2003    fn fact_kind(fact: &StepFact) -> &'static str {
2004        match fact {
2005            StepFact::CeremonyStarted { .. } => "ceremony_started",
2006            StepFact::ActStarted { .. } => "act_started",
2007            StepFact::StepStarted { .. } => "step_started",
2008            StepFact::PromptAnswered { .. } => "prompt_answered",
2009            StepFact::BackendOperation { .. } => "backend_operation",
2010            StepFact::AttestationRecorded { .. } => "attestation_recorded",
2011            StepFact::ArtifactWritten { .. } => "artifact_written",
2012            StepFact::DeviationRecorded { .. } => "deviation_recorded",
2013            StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
2014            StepFact::StepCompleted { .. } => "step_completed",
2015            StepFact::CeremonyCompleted { .. } => "ceremony_completed",
2016            StepFact::CeremonyFailed { .. } => "ceremony_failed",
2017            StepFact::EntropySeeded { .. } => "entropy_seeded",
2018            StepFact::EntropyContributed { .. } => "entropy_contributed",
2019            StepFact::EntropyDrawn { .. } => "entropy_drawn",
2020            StepFact::RoleDeclared { .. } => "role_declared",
2021            StepFact::RoleAssigned { .. } => "role_assigned",
2022            StepFact::ParameterBound { .. } => "parameter_bound",
2023            StepFact::MaterialLoaded { .. } => "material_loaded",
2024            StepFact::MaterialDigest { .. } => "material_digest",
2025            StepFact::BackendBound { .. } => "backend_bound",
2026            StepFact::MachineInfoRecorded { .. } => "machine_info_recorded",
2027            _ => "unknown",
2028        }
2029    }
2030}