Skip to main content

rite_model/
transcript.rs

1//! Persisted transcript schema.
2//!
3//! These types are the **durable** audit surface: what gets written to
4//! `transcript.jsonl`, what `rite verify` reads back, what report and
5//! audit tooling consume. They are deliberately kept independent of the
6//! executor and the channel plumbing, a third-party verifier can parse
7//! a transcript with only `rite-model` on its dependency list.
8//!
9//! The live runtime↔frontend channel protocol (`ExecEvent`, `UiCommand`,
10//! `Response`, `Icon`, …) lives in `rite-runtime` next to the executor
11//! that owns the channels. The boundary is **persisted vs in-flight**.
12
13use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16
17use crate::ir::{ActId, RoleId, StepId};
18
19/// Validator applied by the runtime to a free-form text or literal response
20/// before it is accepted.
21#[non_exhaustive]
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
24pub enum ValidatorSpec {
25    /// Reject empty or whitespace-only input.
26    NonEmpty,
27    /// Input must match this regular expression.
28    Regex(String),
29    /// Named, runtime-defined predicate (e.g. `serial_number`).
30    Predefined(String),
31}
32
33/// Request for user input, recorded into the transcript as part of
34/// [`StepFact::PromptAnswered`].
35#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum Prompt {
39    /// Yes / no question with an optional default.
40    Confirm {
41        /// Question shown to the user.
42        question: String,
43        /// Default selection if the user presses Enter without choosing.
44        default: Option<bool>,
45    },
46    /// Free-form text input, validated against a [`ValidatorSpec`].
47    Text {
48        /// Label shown to the user.
49        label: String,
50        /// Validator applied before the response is accepted.
51        validator: ValidatorSpec,
52    },
53    /// Sensitive input (PIN, password). Echo is suppressed; plaintext is
54    /// never serialized to the transcript.
55    Secret {
56        /// Label shown to the user.
57        label: String,
58    },
59    /// User must type a specific literal string exactly. Validation is
60    /// performed by the runtime against `expected`.
61    Literal {
62        /// Label shown to the user.
63        label: String,
64        /// Exact string the user must type.
65        expected: String,
66    },
67    /// Wait for the user to acknowledge before proceeding. Used for pacing.
68    Continue {
69        /// Optional hint such as "Press Enter when ready".
70        hint: Option<String>,
71    },
72}
73
74/// Serializable, redacted form of a user response.
75///
76/// Used inside [`StepFact::PromptAnswered`] so that the transcript records
77/// what was answered without ever persisting plaintext secrets. The
78/// in-flight `Response` type lives next to the channel protocol in
79/// `rite-runtime`; conversion happens at the moment the prompt is accepted.
80#[non_exhaustive]
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(tag = "type", rename_all = "snake_case")]
83pub enum ResponseRecord {
84    /// Yes / no answer.
85    Bool {
86        /// The answer.
87        value: bool,
88    },
89    /// Free-form text answer.
90    Text {
91        /// The answer.
92        value: String,
93    },
94    /// Secret answer, replaced by a deterministic hash of the plaintext so
95    /// verifiers can confirm a specific secret was provided without seeing it.
96    SecretRedacted {
97        /// Lowercase hex of `sha256(plaintext)`.
98        sha256_of_plaintext: String,
99    },
100    /// Acknowledgement of a [`Prompt::Continue`].
101    Acknowledged,
102}
103
104/// Audit classification of a bad outcome, recorded so an auditor can tell the
105/// nature of a failure apart without parsing the free-form `message`.
106///
107/// This is the *audit* taxonomy (what an auditor sees), distinct from the
108/// runtime's `Retriability` (whether a step may re-run). For a backend error
109/// the two align: a retriable error is `Environmental`. They are kept separate
110/// because some classes never map cleanly onto retriability (an `Abort` is a
111/// decision, not an error).
112#[non_exhaustive]
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ErrorClass {
116    /// The world wasn't ready; the step's work did not happen (token absent,
117    /// loose cable, PIN required).
118    Environmental,
119    /// The ceremony's own logic concluded badly (a verification mismatch, a
120    /// refused attestation). A result, not a recoverable condition.
121    Procedural,
122    /// The run itself is compromised or the definition is broken (transcript
123    /// write failed, channel lost, unknown action, invalid params).
124    Integrity,
125    /// The operator chose to stop. Not an error at all, but recorded on the
126    /// terminal fact so abort is distinguishable from failure.
127    Abort,
128}
129
130/// Structured error record for transcript serialization.
131#[non_exhaustive]
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct ErrorRecord {
134    /// Audit classification of this error.
135    pub class: ErrorClass,
136    /// Stable kind label (e.g. `aborted`, `step_failed`, `material_load_failed`).
137    pub kind: String,
138    /// Human-readable message.
139    pub message: String,
140}
141
142impl ErrorRecord {
143    /// Construct an error record.
144    pub fn new(class: ErrorClass, kind: impl Into<String>, message: impl Into<String>) -> Self {
145        Self {
146            class,
147            kind: kind.into(),
148            message: message.into(),
149        }
150    }
151}
152
153/// Outcome of a single step, carried by [`StepFact::StepCompleted`].
154#[non_exhaustive]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(tag = "status", rename_all = "snake_case")]
157pub enum StepOutcome {
158    /// Step executed successfully.
159    Completed {
160        /// Human-readable completion message.
161        message: String,
162    },
163}
164
165/// Durable, transcript-worthy fact.
166///
167/// Every variant is recorded by the runtime's transcript sink synchronously
168/// before being forwarded to the UI. Action handlers emit
169/// [`BackendOperation`] and [`AttestationRecorded`]; all other variants are
170/// emitted by the executor at the corresponding lifecycle boundary.
171///
172/// [`BackendOperation`]: StepFact::BackendOperation
173/// [`AttestationRecorded`]: StepFact::AttestationRecorded
174#[non_exhaustive]
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177pub enum StepFact {
178    /// Ceremony has started running.
179    CeremonyStarted {
180        /// Ceremony name from the DSL.
181        name: String,
182    },
183    /// Beginning of an act (named subdivision of a ceremony).
184    ActStarted {
185        /// Act identifier.
186        id: ActId,
187        /// Act label as authored in the DSL.
188        label: String,
189    },
190    /// Beginning of a step.
191    StepStarted {
192        /// Step identifier.
193        id: StepId,
194        /// Step label as authored in the DSL.
195        label: String,
196        /// Role responsible for this step (stable id).
197        role: RoleId,
198        /// Human-readable role name, from the ceremony's role definition.
199        /// Carried alongside the id so transcripts stay self-contained for
200        /// reports and verifiers without re-reading the ceremony YAML.
201        role_name: String,
202    },
203    /// A prompt has been answered and validated.
204    PromptAnswered {
205        /// Step that issued the prompt, if any. `None` for ceremony-level
206        /// prompts emitted before the first step (e.g. the ceremony-start
207        /// confirmation) or after the last step.
208        #[serde(skip_serializing_if = "Option::is_none")]
209        step: Option<StepId>,
210        /// The prompt as issued.
211        prompt: Prompt,
212        /// Redacted response record.
213        response: ResponseRecord,
214    },
215    /// A backend operation completed and produced structured evidence.
216    BackendOperation {
217        /// Step under which the operation ran.
218        step: StepId,
219        /// Stable operation kind (e.g. `generate_keypair`, `sign_data`).
220        kind: String,
221        /// Structured inputs to the operation (parameters, references).
222        inputs: serde_json::Value,
223        /// Structured outputs from the operation (artifact ids, hashes).
224        outputs: serde_json::Value,
225        /// Optional fingerprint of the produced material.
226        fingerprint: Option<String>,
227    },
228    /// A human attestation was recorded.
229    AttestationRecorded {
230        /// Step under which the attestation was recorded.
231        step: StepId,
232        /// Role that issued the attestation.
233        role: RoleId,
234        /// Verbatim attestation statement.
235        statement: String,
236    },
237    /// An artifact was written to disk.
238    ArtifactWritten {
239        /// Step that produced the artifact.
240        step: StepId,
241        /// Artifact name as declared in the DSL.
242        name: String,
243        /// Path on disk.
244        path: PathBuf,
245        /// Lowercase hex SHA-256 of the artifact bytes.
246        sha256: String,
247    },
248    /// A deviation was logged by the operator.
249    DeviationRecorded {
250        /// Step in which the deviation was logged, if any. `None` for
251        /// deviations logged outside of a step (before the first step or
252        /// while a ceremony-level prompt is pending).
253        #[serde(skip_serializing_if = "Option::is_none")]
254        step: Option<StepId>,
255        /// Verbatim deviation text.
256        text: String,
257    },
258    /// A step attempt failed. Recorded per attempt, so a retried step shows
259    /// `StepAttemptFailed{attempt: 1}` followed by the operator's retry
260    /// decision and, on success, `StepCompleted`. The final attempt of a step
261    /// that the run gives up on is followed by the terminal `CeremonyFailed`.
262    StepAttemptFailed {
263        /// Step whose attempt failed.
264        step: StepId,
265        /// 1-based attempt number within this step.
266        attempt: u32,
267        /// Structured error record for the failed attempt.
268        error: ErrorRecord,
269    },
270    /// Step finished executing.
271    StepCompleted {
272        /// Step identifier.
273        id: StepId,
274        /// Outcome (completed or skipped).
275        outcome: StepOutcome,
276    },
277    /// Ceremony finished successfully.
278    ///
279    /// The transcript's cryptographic identity is `SHA-256(line_bytes)`
280    /// for this line, recoverable by any reader, so the fact itself
281    /// carries no fingerprint field. The runtime forwards the value to
282    /// frontends through an out-of-band channel event.
283    CeremonyCompleted {},
284    /// Ceremony failed or was aborted.
285    CeremonyFailed {
286        /// Structured error record.
287        error: ErrorRecord,
288    },
289    /// The ceremony entropy source was seeded with machine randomness.
290    ///
291    /// Emitted once by the runner at ceremony start (run-metadata). Records
292    /// the machine contribution `m` and the frozen derivation scheme so any
293    /// value the ceremony later draws is re-derivable from the transcript
294    /// alone. Part of the [entropy source](StepFact::EntropyDrawn) family.
295    ///
296    /// Like every fact, it is timed by the `at` on its chain envelope.
297    EntropySeeded {
298        /// Lowercase hex of the gathered machine entropy `m`.
299        m: String,
300        /// Provenance of `m` (e.g. `os`). A single label today; comma-separated
301        /// if more than one source is ever mixed.
302        source: String,
303        /// Frozen derivation-scheme tag (e.g. `rite-kdf/v1`) that pins the
304        /// entire construction. A verifier rejects an unrecognised value.
305        derivation: String,
306    },
307    /// A human folded additional entropy into the seed, advancing the ratchet.
308    ///
309    /// Emitted by the authored `gather_entropy` step. The verbatim operator
310    /// contribution is recorded so the epoch chain re-folds identically; it is
311    /// public, witnessed entropy, not a secret. Timed by its enclosing step
312    /// boundaries (and by the `PromptAnswered` that captured the input).
313    EntropyContributed {
314        /// Step under which the contribution was gathered.
315        step: StepId,
316        /// Epoch index produced by this fold (1 for the first contribution).
317        epoch: u32,
318        /// Verbatim operator contribution, fed as UTF-8 into the ratchet.
319        contribution: String,
320    },
321    /// A value was drawn from the entropy source (a nonce, certificate serial,
322    /// or challenge).
323    ///
324    /// Emitted whenever an action draws bytes from the entropy source. The
325    /// derivation `path` plus the recorded seed let `rite verify` re-derive
326    /// the value and confirm the right value reached the right consumer. Like
327    /// other action-emitted evidence, it is timed by its enclosing step.
328    EntropyDrawn {
329        /// Step that drew the value.
330        step: StepId,
331        /// Derivation path `<epoch>/<step>/<purpose>`.
332        path: String,
333        /// Lowercase hex of the derived bytes. Its length fixes the byte count,
334        /// so `rite verify` re-derives exactly this many bytes from the seed.
335        value: String,
336    },
337}
338
339/// JSON-shape snapshot tests, the tripwire for accidental wire-format breaks.
340///
341/// Every variant of [`StepFact`], [`Prompt`], [`ResponseRecord`], [`StepOutcome`],
342/// and [`ValidatorSpec`] is serialized once with fixed payloads and compared
343/// against an inline JSON literal. The on-disk transcript schema is what
344/// `rite verify`, `rite report`, and any third-party verifier consume; a
345/// rename, a `serde(tag)` change, a `rename_all` flip, or a timestamp format
346/// swap must surface here, not in the field.
347///
348/// Breaking the format is allowed in early beta, **deliberately**, update
349/// the fixture in the same commit so the diff documents the wire change.
350#[cfg(test)]
351mod schema_snapshot_tests {
352    use super::*;
353    use serde_json::json;
354
355    fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
356        let actual = serde_json::to_value(fact).expect("serialize StepFact");
357        assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
358    }
359
360    #[test]
361    fn ceremony_started() {
362        assert_json(
363            &StepFact::CeremonyStarted {
364                name: "Root CA".to_string(),
365            },
366            &json!({
367                "type": "ceremony_started",
368                "name": "Root CA",
369            }),
370        );
371    }
372
373    #[test]
374    fn act_started() {
375        assert_json(
376            &StepFact::ActStarted {
377                id: ActId::new("setup"),
378                label: "Setup".to_string(),
379            },
380            &json!({
381                "type": "act_started",
382                "id": "setup",
383                "label": "Setup",
384            }),
385        );
386    }
387
388    #[test]
389    fn step_started() {
390        assert_json(
391            &StepFact::StepStarted {
392                id: StepId::new("s1"),
393                label: "2.1".to_string(),
394                role: RoleId::new("crypto_officer"),
395                role_name: "Crypto Officer".to_string(),
396            },
397            &json!({
398                "type": "step_started",
399                "id": "s1",
400                "label": "2.1",
401                "role": "crypto_officer",
402                "role_name": "Crypto Officer",
403            }),
404        );
405    }
406
407    #[test]
408    fn prompt_answered_confirm_bool() {
409        assert_json(
410            &StepFact::PromptAnswered {
411                step: Some(StepId::new("s1")),
412                prompt: Prompt::Confirm {
413                    question: "Proceed?".to_string(),
414                    default: Some(true),
415                },
416                response: ResponseRecord::Bool { value: true },
417            },
418            &json!({
419                "type": "prompt_answered",
420                "step": "s1",
421                "prompt": { "type": "confirm", "question": "Proceed?", "default": true },
422                "response": { "type": "bool", "value": true },
423            }),
424        );
425    }
426
427    #[test]
428    fn prompt_answered_text_nonempty() {
429        assert_json(
430            &StepFact::PromptAnswered {
431                step: Some(StepId::new("s1")),
432                prompt: Prompt::Text {
433                    label: "Name".to_string(),
434                    validator: ValidatorSpec::NonEmpty,
435                },
436                response: ResponseRecord::Text {
437                    value: "Alice".to_string(),
438                },
439            },
440            &json!({
441                "type": "prompt_answered",
442                "step": "s1",
443                "prompt": {
444                    "type": "text",
445                    "label": "Name",
446                    "validator": { "kind": "non_empty" },
447                },
448                "response": { "type": "text", "value": "Alice" },
449            }),
450        );
451    }
452
453    #[test]
454    fn prompt_answered_text_regex() {
455        assert_json(
456            &StepFact::PromptAnswered {
457                step: Some(StepId::new("s1")),
458                prompt: Prompt::Text {
459                    label: "SN".to_string(),
460                    validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
461                },
462                response: ResponseRecord::Text {
463                    value: "AB12".to_string(),
464                },
465            },
466            &json!({
467                "type": "prompt_answered",
468                "step": "s1",
469                "prompt": {
470                    "type": "text",
471                    "label": "SN",
472                    "validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
473                },
474                "response": { "type": "text", "value": "AB12" },
475            }),
476        );
477    }
478
479    #[test]
480    fn prompt_answered_text_predefined() {
481        assert_json(
482            &StepFact::PromptAnswered {
483                step: Some(StepId::new("s1")),
484                prompt: Prompt::Text {
485                    label: "SN".to_string(),
486                    validator: ValidatorSpec::Predefined("serial_number".to_string()),
487                },
488                response: ResponseRecord::Text {
489                    value: "ABCD".to_string(),
490                },
491            },
492            &json!({
493                "type": "prompt_answered",
494                "step": "s1",
495                "prompt": {
496                    "type": "text",
497                    "label": "SN",
498                    "validator": { "kind": "predefined", "value": "serial_number" },
499                },
500                "response": { "type": "text", "value": "ABCD" },
501            }),
502        );
503    }
504
505    #[test]
506    fn prompt_answered_secret_redacted() {
507        assert_json(
508            &StepFact::PromptAnswered {
509                step: Some(StepId::new("s1")),
510                prompt: Prompt::Secret {
511                    label: "PIN".to_string(),
512                },
513                response: ResponseRecord::SecretRedacted {
514                    sha256_of_plaintext: "f".repeat(64),
515                },
516            },
517            &json!({
518                "type": "prompt_answered",
519                "step": "s1",
520                "prompt": { "type": "secret", "label": "PIN" },
521                "response": {
522                    "type": "secret_redacted",
523                    "sha256_of_plaintext": "f".repeat(64),
524                },
525            }),
526        );
527    }
528
529    #[test]
530    fn prompt_answered_literal_text() {
531        assert_json(
532            &StepFact::PromptAnswered {
533                step: Some(StepId::new("s1")),
534                prompt: Prompt::Literal {
535                    label: "Type 'attest'".to_string(),
536                    expected: "attest".to_string(),
537                },
538                response: ResponseRecord::Text {
539                    value: "attest".to_string(),
540                },
541            },
542            &json!({
543                "type": "prompt_answered",
544                "step": "s1",
545                "prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
546                "response": { "type": "text", "value": "attest" },
547            }),
548        );
549    }
550
551    #[test]
552    fn prompt_answered_continue_acknowledged() {
553        assert_json(
554            &StepFact::PromptAnswered {
555                step: Some(StepId::new("s1")),
556                prompt: Prompt::Continue {
557                    hint: Some("Press Enter".to_string()),
558                },
559                response: ResponseRecord::Acknowledged,
560            },
561            &json!({
562                "type": "prompt_answered",
563                "step": "s1",
564                "prompt": { "type": "continue", "hint": "Press Enter" },
565                "response": { "type": "acknowledged" },
566            }),
567        );
568    }
569
570    #[test]
571    fn backend_operation() {
572        assert_json(
573            &StepFact::BackendOperation {
574                step: StepId::new("s1"),
575                kind: "generate_keypair".to_string(),
576                inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
577                outputs: json!({ "key_id": "k1" }),
578                fingerprint: Some("sha256:deadbeef".to_string()),
579            },
580            &json!({
581                "type": "backend_operation",
582                "step": "s1",
583                "kind": "generate_keypair",
584                "inputs": { "algorithm": "rsa", "bits": 4096 },
585                "outputs": { "key_id": "k1" },
586                "fingerprint": "sha256:deadbeef",
587            }),
588        );
589    }
590
591    #[test]
592    fn attestation_recorded() {
593        assert_json(
594            &StepFact::AttestationRecorded {
595                step: StepId::new("s1"),
596                role: RoleId::new("crypto_officer"),
597                statement: "I confirm.".to_string(),
598            },
599            &json!({
600                "type": "attestation_recorded",
601                "step": "s1",
602                "role": "crypto_officer",
603                "statement": "I confirm.",
604            }),
605        );
606    }
607
608    #[test]
609    fn artifact_written() {
610        assert_json(
611            &StepFact::ArtifactWritten {
612                step: StepId::new("s1"),
613                name: "root.crt".to_string(),
614                path: "/out/root.crt".into(),
615                sha256: "a".repeat(64),
616            },
617            &json!({
618                "type": "artifact_written",
619                "step": "s1",
620                "name": "root.crt",
621                "path": "/out/root.crt",
622                "sha256": "a".repeat(64),
623            }),
624        );
625    }
626
627    #[test]
628    fn deviation_recorded() {
629        assert_json(
630            &StepFact::DeviationRecorded {
631                step: Some(StepId::new("s1")),
632                text: "phone rang".to_string(),
633            },
634            &json!({
635                "type": "deviation_recorded",
636                "step": "s1",
637                "text": "phone rang",
638            }),
639        );
640    }
641
642    #[test]
643    fn step_completed_completed() {
644        assert_json(
645            &StepFact::StepCompleted {
646                id: StepId::new("s1"),
647                outcome: StepOutcome::Completed {
648                    message: "done".to_string(),
649                },
650            },
651            &json!({
652                "type": "step_completed",
653                "id": "s1",
654                "outcome": { "status": "completed", "message": "done" },
655            }),
656        );
657    }
658
659    #[test]
660    fn ceremony_completed() {
661        assert_json(
662            &StepFact::CeremonyCompleted {},
663            &json!({
664                "type": "ceremony_completed",
665            }),
666        );
667    }
668
669    #[test]
670    fn ceremony_failed() {
671        assert_json(
672            &StepFact::CeremonyFailed {
673                error: ErrorRecord::new(
674                    ErrorClass::Abort,
675                    "aborted",
676                    "ceremony aborted by operator",
677                ),
678            },
679            &json!({
680                "type": "ceremony_failed",
681                "error": {
682                    "class": "abort",
683                    "kind": "aborted",
684                    "message": "ceremony aborted by operator",
685                },
686            }),
687        );
688    }
689
690    #[test]
691    fn step_attempt_failed() {
692        assert_json(
693            &StepFact::StepAttemptFailed {
694                step: StepId::new("import_key"),
695                attempt: 1,
696                error: ErrorRecord::new(
697                    ErrorClass::Environmental,
698                    "backend_error",
699                    "Token not present",
700                ),
701            },
702            &json!({
703                "type": "step_attempt_failed",
704                "step": "import_key",
705                "attempt": 1,
706                "error": {
707                    "class": "environmental",
708                    "kind": "backend_error",
709                    "message": "Token not present",
710                },
711            }),
712        );
713    }
714
715    #[test]
716    fn entropy_seeded() {
717        assert_json(
718            &StepFact::EntropySeeded {
719                m: "00112233".to_string(),
720                source: "os".to_string(),
721                derivation: "rite-kdf/v1".to_string(),
722            },
723            &json!({
724                "type": "entropy_seeded",
725                "m": "00112233",
726                "source": "os",
727                "derivation": "rite-kdf/v1",
728            }),
729        );
730    }
731
732    #[test]
733    fn entropy_contributed() {
734        assert_json(
735            &StepFact::EntropyContributed {
736                step: StepId::new("roll_dice"),
737                epoch: 1,
738                contribution: "3 1 6 4 2 5".to_string(),
739            },
740            &json!({
741                "type": "entropy_contributed",
742                "step": "roll_dice",
743                "epoch": 1,
744                "contribution": "3 1 6 4 2 5",
745            }),
746        );
747    }
748
749    #[test]
750    fn entropy_drawn() {
751        assert_json(
752            &StepFact::EntropyDrawn {
753                step: StepId::new("issue"),
754                path: "0/issue/cert-serial".to_string(),
755                value: "aabbccddeeff00112233".to_string(),
756            },
757            &json!({
758                "type": "entropy_drawn",
759                "step": "issue",
760                "path": "0/issue/cert-serial",
761                "value": "aabbccddeeff00112233",
762            }),
763        );
764    }
765}