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/// Structured error record for transcript serialization.
105#[non_exhaustive]
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ErrorRecord {
108    /// Stable kind label (e.g. `aborted`, `step_failed`, `material_load_failed`).
109    pub kind: String,
110    /// Human-readable message.
111    pub message: String,
112}
113
114impl ErrorRecord {
115    /// Construct an error record.
116    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
117        Self {
118            kind: kind.into(),
119            message: message.into(),
120        }
121    }
122}
123
124/// Outcome of a single step, carried by [`StepFact::StepCompleted`].
125#[non_exhaustive]
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(tag = "status", rename_all = "snake_case")]
128pub enum StepOutcome {
129    /// Step executed successfully.
130    Completed {
131        /// Human-readable completion message.
132        message: String,
133    },
134}
135
136/// Durable, transcript-worthy fact.
137///
138/// Every variant is recorded by the runtime's transcript sink synchronously
139/// before being forwarded to the UI. Action handlers emit
140/// [`BackendOperation`] and [`AttestationRecorded`]; all other variants are
141/// emitted by the executor at the corresponding lifecycle boundary.
142///
143/// [`BackendOperation`]: StepFact::BackendOperation
144/// [`AttestationRecorded`]: StepFact::AttestationRecorded
145#[non_exhaustive]
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(tag = "type", rename_all = "snake_case")]
148pub enum StepFact {
149    /// Ceremony has started running.
150    CeremonyStarted {
151        /// Ceremony name from the DSL.
152        name: String,
153    },
154    /// Beginning of an act (named subdivision of a ceremony).
155    ActStarted {
156        /// Act identifier.
157        id: ActId,
158        /// Act label as authored in the DSL.
159        label: String,
160    },
161    /// Beginning of a step.
162    StepStarted {
163        /// Step identifier.
164        id: StepId,
165        /// Step label as authored in the DSL.
166        label: String,
167        /// Role responsible for this step (stable id).
168        role: RoleId,
169        /// Human-readable role name, from the ceremony's role definition.
170        /// Carried alongside the id so transcripts stay self-contained for
171        /// reports and verifiers without re-reading the ceremony YAML.
172        role_name: String,
173    },
174    /// A prompt has been answered and validated.
175    PromptAnswered {
176        /// Step that issued the prompt, if any. `None` for ceremony-level
177        /// prompts emitted before the first step (e.g. the ceremony-start
178        /// confirmation) or after the last step.
179        #[serde(skip_serializing_if = "Option::is_none")]
180        step: Option<StepId>,
181        /// The prompt as issued.
182        prompt: Prompt,
183        /// Redacted response record.
184        response: ResponseRecord,
185    },
186    /// A backend operation completed and produced structured evidence.
187    BackendOperation {
188        /// Step under which the operation ran.
189        step: StepId,
190        /// Stable operation kind (e.g. `generate_keypair`, `sign_data`).
191        kind: String,
192        /// Structured inputs to the operation (parameters, references).
193        inputs: serde_json::Value,
194        /// Structured outputs from the operation (artifact ids, hashes).
195        outputs: serde_json::Value,
196        /// Optional fingerprint of the produced material.
197        fingerprint: Option<String>,
198    },
199    /// A human attestation was recorded.
200    AttestationRecorded {
201        /// Step under which the attestation was recorded.
202        step: StepId,
203        /// Role that issued the attestation.
204        role: RoleId,
205        /// Verbatim attestation statement.
206        statement: String,
207    },
208    /// An artifact was written to disk.
209    ArtifactWritten {
210        /// Step that produced the artifact.
211        step: StepId,
212        /// Artifact name as declared in the DSL.
213        name: String,
214        /// Path on disk.
215        path: PathBuf,
216        /// Lowercase hex SHA-256 of the artifact bytes.
217        sha256: String,
218    },
219    /// A deviation was logged by the operator.
220    DeviationRecorded {
221        /// Step in which the deviation was logged, if any. `None` for
222        /// deviations logged outside of a step (before the first step or
223        /// while a ceremony-level prompt is pending).
224        #[serde(skip_serializing_if = "Option::is_none")]
225        step: Option<StepId>,
226        /// Verbatim deviation text.
227        text: String,
228    },
229    /// Step finished executing.
230    StepCompleted {
231        /// Step identifier.
232        id: StepId,
233        /// Outcome (completed or skipped).
234        outcome: StepOutcome,
235    },
236    /// Ceremony finished successfully.
237    ///
238    /// The transcript's cryptographic identity is `SHA-256(line_bytes)`
239    /// for this line, recoverable by any reader, so the fact itself
240    /// carries no fingerprint field. The runtime forwards the value to
241    /// frontends through an out-of-band channel event.
242    CeremonyCompleted {},
243    /// Ceremony failed or was aborted.
244    CeremonyFailed {
245        /// Structured error record.
246        error: ErrorRecord,
247    },
248    /// The ceremony entropy source was seeded with machine randomness.
249    ///
250    /// Emitted once by the runner at ceremony start (run-metadata). Records
251    /// the machine contribution `m` and the frozen derivation scheme so any
252    /// value the ceremony later draws is re-derivable from the transcript
253    /// alone. Part of the [entropy source](StepFact::EntropyDrawn) family.
254    ///
255    /// Like every fact, it is timed by the `at` on its chain envelope.
256    EntropySeeded {
257        /// Lowercase hex of the gathered machine entropy `m`.
258        m: String,
259        /// Provenance of `m` (e.g. `os`). A single label today; comma-separated
260        /// if more than one source is ever mixed.
261        source: String,
262        /// Frozen derivation-scheme tag (e.g. `rite-kdf/v1`) that pins the
263        /// entire construction. A verifier rejects an unrecognised value.
264        derivation: String,
265    },
266    /// A human folded additional entropy into the seed, advancing the ratchet.
267    ///
268    /// Emitted by the authored `gather_entropy` step. The verbatim operator
269    /// contribution is recorded so the epoch chain re-folds identically; it is
270    /// public, witnessed entropy, not a secret. Timed by its enclosing step
271    /// boundaries (and by the `PromptAnswered` that captured the input).
272    EntropyContributed {
273        /// Step under which the contribution was gathered.
274        step: StepId,
275        /// Epoch index produced by this fold (1 for the first contribution).
276        epoch: u32,
277        /// Verbatim operator contribution, fed as UTF-8 into the ratchet.
278        contribution: String,
279    },
280    /// A value was drawn from the entropy source (a nonce, certificate serial,
281    /// or challenge).
282    ///
283    /// Emitted whenever an action draws bytes from the entropy source. The
284    /// derivation `path` plus the recorded seed let `rite verify` re-derive
285    /// the value and confirm the right value reached the right consumer. Like
286    /// other action-emitted evidence, it is timed by its enclosing step.
287    EntropyDrawn {
288        /// Step that drew the value.
289        step: StepId,
290        /// Derivation path `<epoch>/<step>/<purpose>`.
291        path: String,
292        /// Lowercase hex of the derived bytes. Its length fixes the byte count,
293        /// so `rite verify` re-derives exactly this many bytes from the seed.
294        value: String,
295    },
296}
297
298/// JSON-shape snapshot tests, the tripwire for accidental wire-format breaks.
299///
300/// Every variant of [`StepFact`], [`Prompt`], [`ResponseRecord`], [`StepOutcome`],
301/// and [`ValidatorSpec`] is serialized once with fixed payloads and compared
302/// against an inline JSON literal. The on-disk transcript schema is what
303/// `rite verify`, `rite report`, and any third-party verifier consume; a
304/// rename, a `serde(tag)` change, a `rename_all` flip, or a timestamp format
305/// swap must surface here, not in the field.
306///
307/// Breaking the format is allowed in early beta, **deliberately**, update
308/// the fixture in the same commit so the diff documents the wire change.
309#[cfg(test)]
310mod schema_snapshot_tests {
311    use super::*;
312    use serde_json::json;
313
314    fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
315        let actual = serde_json::to_value(fact).expect("serialize StepFact");
316        assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
317    }
318
319    #[test]
320    fn ceremony_started() {
321        assert_json(
322            &StepFact::CeremonyStarted {
323                name: "Root CA".to_string(),
324            },
325            &json!({
326                "type": "ceremony_started",
327                "name": "Root CA",
328            }),
329        );
330    }
331
332    #[test]
333    fn act_started() {
334        assert_json(
335            &StepFact::ActStarted {
336                id: ActId::new("setup"),
337                label: "Setup".to_string(),
338            },
339            &json!({
340                "type": "act_started",
341                "id": "setup",
342                "label": "Setup",
343            }),
344        );
345    }
346
347    #[test]
348    fn step_started() {
349        assert_json(
350            &StepFact::StepStarted {
351                id: StepId::new("s1"),
352                label: "2.1".to_string(),
353                role: RoleId::new("crypto_officer"),
354                role_name: "Crypto Officer".to_string(),
355            },
356            &json!({
357                "type": "step_started",
358                "id": "s1",
359                "label": "2.1",
360                "role": "crypto_officer",
361                "role_name": "Crypto Officer",
362            }),
363        );
364    }
365
366    #[test]
367    fn prompt_answered_confirm_bool() {
368        assert_json(
369            &StepFact::PromptAnswered {
370                step: Some(StepId::new("s1")),
371                prompt: Prompt::Confirm {
372                    question: "Proceed?".to_string(),
373                    default: Some(true),
374                },
375                response: ResponseRecord::Bool { value: true },
376            },
377            &json!({
378                "type": "prompt_answered",
379                "step": "s1",
380                "prompt": { "type": "confirm", "question": "Proceed?", "default": true },
381                "response": { "type": "bool", "value": true },
382            }),
383        );
384    }
385
386    #[test]
387    fn prompt_answered_text_nonempty() {
388        assert_json(
389            &StepFact::PromptAnswered {
390                step: Some(StepId::new("s1")),
391                prompt: Prompt::Text {
392                    label: "Name".to_string(),
393                    validator: ValidatorSpec::NonEmpty,
394                },
395                response: ResponseRecord::Text {
396                    value: "Alice".to_string(),
397                },
398            },
399            &json!({
400                "type": "prompt_answered",
401                "step": "s1",
402                "prompt": {
403                    "type": "text",
404                    "label": "Name",
405                    "validator": { "kind": "non_empty" },
406                },
407                "response": { "type": "text", "value": "Alice" },
408            }),
409        );
410    }
411
412    #[test]
413    fn prompt_answered_text_regex() {
414        assert_json(
415            &StepFact::PromptAnswered {
416                step: Some(StepId::new("s1")),
417                prompt: Prompt::Text {
418                    label: "SN".to_string(),
419                    validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
420                },
421                response: ResponseRecord::Text {
422                    value: "AB12".to_string(),
423                },
424            },
425            &json!({
426                "type": "prompt_answered",
427                "step": "s1",
428                "prompt": {
429                    "type": "text",
430                    "label": "SN",
431                    "validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
432                },
433                "response": { "type": "text", "value": "AB12" },
434            }),
435        );
436    }
437
438    #[test]
439    fn prompt_answered_text_predefined() {
440        assert_json(
441            &StepFact::PromptAnswered {
442                step: Some(StepId::new("s1")),
443                prompt: Prompt::Text {
444                    label: "SN".to_string(),
445                    validator: ValidatorSpec::Predefined("serial_number".to_string()),
446                },
447                response: ResponseRecord::Text {
448                    value: "ABCD".to_string(),
449                },
450            },
451            &json!({
452                "type": "prompt_answered",
453                "step": "s1",
454                "prompt": {
455                    "type": "text",
456                    "label": "SN",
457                    "validator": { "kind": "predefined", "value": "serial_number" },
458                },
459                "response": { "type": "text", "value": "ABCD" },
460            }),
461        );
462    }
463
464    #[test]
465    fn prompt_answered_secret_redacted() {
466        assert_json(
467            &StepFact::PromptAnswered {
468                step: Some(StepId::new("s1")),
469                prompt: Prompt::Secret {
470                    label: "PIN".to_string(),
471                },
472                response: ResponseRecord::SecretRedacted {
473                    sha256_of_plaintext: "f".repeat(64),
474                },
475            },
476            &json!({
477                "type": "prompt_answered",
478                "step": "s1",
479                "prompt": { "type": "secret", "label": "PIN" },
480                "response": {
481                    "type": "secret_redacted",
482                    "sha256_of_plaintext": "f".repeat(64),
483                },
484            }),
485        );
486    }
487
488    #[test]
489    fn prompt_answered_literal_text() {
490        assert_json(
491            &StepFact::PromptAnswered {
492                step: Some(StepId::new("s1")),
493                prompt: Prompt::Literal {
494                    label: "Type 'attest'".to_string(),
495                    expected: "attest".to_string(),
496                },
497                response: ResponseRecord::Text {
498                    value: "attest".to_string(),
499                },
500            },
501            &json!({
502                "type": "prompt_answered",
503                "step": "s1",
504                "prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
505                "response": { "type": "text", "value": "attest" },
506            }),
507        );
508    }
509
510    #[test]
511    fn prompt_answered_continue_acknowledged() {
512        assert_json(
513            &StepFact::PromptAnswered {
514                step: Some(StepId::new("s1")),
515                prompt: Prompt::Continue {
516                    hint: Some("Press Enter".to_string()),
517                },
518                response: ResponseRecord::Acknowledged,
519            },
520            &json!({
521                "type": "prompt_answered",
522                "step": "s1",
523                "prompt": { "type": "continue", "hint": "Press Enter" },
524                "response": { "type": "acknowledged" },
525            }),
526        );
527    }
528
529    #[test]
530    fn backend_operation() {
531        assert_json(
532            &StepFact::BackendOperation {
533                step: StepId::new("s1"),
534                kind: "generate_keypair".to_string(),
535                inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
536                outputs: json!({ "key_id": "k1" }),
537                fingerprint: Some("sha256:deadbeef".to_string()),
538            },
539            &json!({
540                "type": "backend_operation",
541                "step": "s1",
542                "kind": "generate_keypair",
543                "inputs": { "algorithm": "rsa", "bits": 4096 },
544                "outputs": { "key_id": "k1" },
545                "fingerprint": "sha256:deadbeef",
546            }),
547        );
548    }
549
550    #[test]
551    fn attestation_recorded() {
552        assert_json(
553            &StepFact::AttestationRecorded {
554                step: StepId::new("s1"),
555                role: RoleId::new("crypto_officer"),
556                statement: "I confirm.".to_string(),
557            },
558            &json!({
559                "type": "attestation_recorded",
560                "step": "s1",
561                "role": "crypto_officer",
562                "statement": "I confirm.",
563            }),
564        );
565    }
566
567    #[test]
568    fn artifact_written() {
569        assert_json(
570            &StepFact::ArtifactWritten {
571                step: StepId::new("s1"),
572                name: "root.crt".to_string(),
573                path: "/out/root.crt".into(),
574                sha256: "a".repeat(64),
575            },
576            &json!({
577                "type": "artifact_written",
578                "step": "s1",
579                "name": "root.crt",
580                "path": "/out/root.crt",
581                "sha256": "a".repeat(64),
582            }),
583        );
584    }
585
586    #[test]
587    fn deviation_recorded() {
588        assert_json(
589            &StepFact::DeviationRecorded {
590                step: Some(StepId::new("s1")),
591                text: "phone rang".to_string(),
592            },
593            &json!({
594                "type": "deviation_recorded",
595                "step": "s1",
596                "text": "phone rang",
597            }),
598        );
599    }
600
601    #[test]
602    fn step_completed_completed() {
603        assert_json(
604            &StepFact::StepCompleted {
605                id: StepId::new("s1"),
606                outcome: StepOutcome::Completed {
607                    message: "done".to_string(),
608                },
609            },
610            &json!({
611                "type": "step_completed",
612                "id": "s1",
613                "outcome": { "status": "completed", "message": "done" },
614            }),
615        );
616    }
617
618    #[test]
619    fn ceremony_completed() {
620        assert_json(
621            &StepFact::CeremonyCompleted {},
622            &json!({
623                "type": "ceremony_completed",
624            }),
625        );
626    }
627
628    #[test]
629    fn ceremony_failed() {
630        assert_json(
631            &StepFact::CeremonyFailed {
632                error: ErrorRecord::new("aborted", "ceremony aborted by operator"),
633            },
634            &json!({
635                "type": "ceremony_failed",
636                "error": { "kind": "aborted", "message": "ceremony aborted by operator" },
637            }),
638        );
639    }
640
641    #[test]
642    fn entropy_seeded() {
643        assert_json(
644            &StepFact::EntropySeeded {
645                m: "00112233".to_string(),
646                source: "os".to_string(),
647                derivation: "rite-kdf/v1".to_string(),
648            },
649            &json!({
650                "type": "entropy_seeded",
651                "m": "00112233",
652                "source": "os",
653                "derivation": "rite-kdf/v1",
654            }),
655        );
656    }
657
658    #[test]
659    fn entropy_contributed() {
660        assert_json(
661            &StepFact::EntropyContributed {
662                step: StepId::new("roll_dice"),
663                epoch: 1,
664                contribution: "3 1 6 4 2 5".to_string(),
665            },
666            &json!({
667                "type": "entropy_contributed",
668                "step": "roll_dice",
669                "epoch": 1,
670                "contribution": "3 1 6 4 2 5",
671            }),
672        );
673    }
674
675    #[test]
676    fn entropy_drawn() {
677        assert_json(
678            &StepFact::EntropyDrawn {
679                step: StepId::new("issue"),
680                path: "0/issue/cert-serial".to_string(),
681                value: "aabbccddeeff00112233".to_string(),
682            },
683            &json!({
684                "type": "entropy_drawn",
685                "step": "issue",
686                "path": "0/issue/cert-serial",
687                "value": "aabbccddeeff00112233",
688            }),
689        );
690    }
691}