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 chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17
18use crate::ir::{ActId, RoleId, StepId};
19
20/// Validator applied by the runtime to a free-form text or literal response
21/// before it is accepted.
22#[non_exhaustive]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
25pub enum ValidatorSpec {
26    /// Reject empty or whitespace-only input.
27    NonEmpty,
28    /// Input must match this regular expression.
29    Regex(String),
30    /// Named, runtime-defined predicate (e.g. `serial_number`).
31    Predefined(String),
32}
33
34/// Request for user input, recorded into the transcript as part of
35/// [`StepFact::PromptAnswered`].
36#[non_exhaustive]
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum Prompt {
40    /// Yes / no question with an optional default.
41    Confirm {
42        /// Question shown to the user.
43        question: String,
44        /// Default selection if the user presses Enter without choosing.
45        default: Option<bool>,
46    },
47    /// Free-form text input, validated against a [`ValidatorSpec`].
48    Text {
49        /// Label shown to the user.
50        label: String,
51        /// Validator applied before the response is accepted.
52        validator: ValidatorSpec,
53    },
54    /// Sensitive input (PIN, password). Echo is suppressed; plaintext is
55    /// never serialized to the transcript.
56    Secret {
57        /// Label shown to the user.
58        label: String,
59    },
60    /// User must type a specific literal string exactly. Validation is
61    /// performed by the runtime against `expected`.
62    Literal {
63        /// Label shown to the user.
64        label: String,
65        /// Exact string the user must type.
66        expected: String,
67    },
68    /// Wait for the user to acknowledge before proceeding. Used for pacing.
69    Continue {
70        /// Optional hint such as "Press Enter when ready".
71        hint: Option<String>,
72    },
73}
74
75/// Serializable, redacted form of a user response.
76///
77/// Used inside [`StepFact::PromptAnswered`] so that the transcript records
78/// what was answered without ever persisting plaintext secrets. The
79/// in-flight `Response` type lives next to the channel protocol in
80/// `rite-runtime`; conversion happens at the moment the prompt is accepted.
81#[non_exhaustive]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum ResponseRecord {
85    /// Yes / no answer.
86    Bool {
87        /// The answer.
88        value: bool,
89    },
90    /// Free-form text answer.
91    Text {
92        /// The answer.
93        value: String,
94    },
95    /// Secret answer, replaced by a deterministic hash of the plaintext so
96    /// verifiers can confirm a specific secret was provided without seeing it.
97    SecretRedacted {
98        /// Lowercase hex of `sha256(plaintext)`.
99        sha256_of_plaintext: String,
100    },
101    /// Acknowledgement of a [`Prompt::Continue`].
102    Acknowledged,
103}
104
105/// Structured error record for transcript serialization.
106#[non_exhaustive]
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ErrorRecord {
109    /// Stable kind label (e.g. `aborted`, `step_failed`, `material_load_failed`).
110    pub kind: String,
111    /// Human-readable message.
112    pub message: String,
113}
114
115impl ErrorRecord {
116    /// Construct an error record.
117    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
118        Self {
119            kind: kind.into(),
120            message: message.into(),
121        }
122    }
123}
124
125/// Outcome of a single step, carried by [`StepFact::StepCompleted`].
126#[non_exhaustive]
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(tag = "status", rename_all = "snake_case")]
129pub enum StepOutcome {
130    /// Step executed successfully.
131    Completed {
132        /// Human-readable completion message.
133        message: String,
134    },
135}
136
137/// Durable, transcript-worthy fact.
138///
139/// Every variant is recorded by the runtime's transcript sink synchronously
140/// before being forwarded to the UI. Action handlers emit
141/// [`BackendOperation`] and [`AttestationRecorded`]; all other variants are
142/// emitted by the executor at the corresponding lifecycle boundary.
143///
144/// [`BackendOperation`]: StepFact::BackendOperation
145/// [`AttestationRecorded`]: StepFact::AttestationRecorded
146#[non_exhaustive]
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(tag = "type", rename_all = "snake_case")]
149pub enum StepFact {
150    /// Ceremony has started running.
151    CeremonyStarted {
152        /// Ceremony name from the DSL.
153        name: String,
154        /// Wall-clock timestamp at start.
155        started_at: DateTime<Utc>,
156    },
157    /// Beginning of an act (named subdivision of a ceremony).
158    ActStarted {
159        /// Act identifier.
160        id: ActId,
161        /// Act label as authored in the DSL.
162        label: String,
163    },
164    /// Beginning of a step.
165    StepStarted {
166        /// Step identifier.
167        id: StepId,
168        /// Step label as authored in the DSL.
169        label: String,
170        /// Role responsible for this step (stable id).
171        role: RoleId,
172        /// Human-readable role name, from the ceremony's role definition.
173        /// Carried alongside the id so transcripts stay self-contained for
174        /// reports and verifiers without re-reading the ceremony YAML.
175        role_name: String,
176        /// Wall-clock timestamp at start.
177        started_at: DateTime<Utc>,
178    },
179    /// A prompt has been answered and validated.
180    PromptAnswered {
181        /// Step that issued the prompt, if any. `None` for ceremony-level
182        /// prompts emitted before the first step (e.g. the ceremony-start
183        /// confirmation) or after the last step.
184        #[serde(skip_serializing_if = "Option::is_none")]
185        step: Option<StepId>,
186        /// The prompt as issued.
187        prompt: Prompt,
188        /// Redacted response record.
189        response: ResponseRecord,
190        /// Wall-clock timestamp when the response was accepted.
191        at: DateTime<Utc>,
192    },
193    /// A backend operation completed and produced structured evidence.
194    BackendOperation {
195        /// Step under which the operation ran.
196        step: StepId,
197        /// Stable operation kind (e.g. `generate_keypair`, `sign_data`).
198        kind: String,
199        /// Structured inputs to the operation (parameters, references).
200        inputs: serde_json::Value,
201        /// Structured outputs from the operation (artifact ids, hashes).
202        outputs: serde_json::Value,
203        /// Optional fingerprint of the produced material.
204        fingerprint: Option<String>,
205    },
206    /// A human attestation was recorded.
207    AttestationRecorded {
208        /// Step under which the attestation was recorded.
209        step: StepId,
210        /// Role that issued the attestation.
211        role: RoleId,
212        /// Verbatim attestation statement.
213        statement: String,
214        /// Wall-clock timestamp when the attestation was recorded.
215        at: DateTime<Utc>,
216    },
217    /// An artifact was written to disk.
218    ArtifactWritten {
219        /// Step that produced the artifact.
220        step: StepId,
221        /// Artifact name as declared in the DSL.
222        name: String,
223        /// Path on disk.
224        path: PathBuf,
225        /// Lowercase hex SHA-256 of the artifact bytes.
226        sha256: String,
227    },
228    /// A deviation was logged by the operator.
229    DeviationRecorded {
230        /// Step in which the deviation was logged, if any. `None` for
231        /// deviations logged outside of a step (before the first step or
232        /// while a ceremony-level prompt is pending).
233        #[serde(skip_serializing_if = "Option::is_none")]
234        step: Option<StepId>,
235        /// Verbatim deviation text.
236        text: String,
237        /// Wall-clock timestamp when the deviation was recorded.
238        at: DateTime<Utc>,
239    },
240    /// Step finished executing.
241    StepCompleted {
242        /// Step identifier.
243        id: StepId,
244        /// Outcome (completed or skipped).
245        outcome: StepOutcome,
246        /// Wall-clock timestamp at completion.
247        completed_at: DateTime<Utc>,
248    },
249    /// Ceremony finished successfully.
250    ///
251    /// The transcript's cryptographic identity is `SHA-256(line_bytes)`
252    /// for this line, recoverable by any reader, so the fact itself
253    /// carries no fingerprint field. The runtime forwards the value to
254    /// frontends through an out-of-band channel event.
255    CeremonyCompleted {
256        /// Wall-clock timestamp at completion.
257        completed_at: DateTime<Utc>,
258    },
259    /// Ceremony failed or was aborted.
260    CeremonyFailed {
261        /// Structured error record.
262        error: ErrorRecord,
263        /// Wall-clock timestamp at failure.
264        failed_at: DateTime<Utc>,
265    },
266}
267
268/// JSON-shape snapshot tests, the tripwire for accidental wire-format breaks.
269///
270/// Every variant of [`StepFact`], [`Prompt`], [`ResponseRecord`], [`StepOutcome`],
271/// and [`ValidatorSpec`] is serialized once with fixed payloads and compared
272/// against an inline JSON literal. The on-disk transcript schema is what
273/// `rite verify`, `rite report`, and any third-party verifier consume; a
274/// rename, a `serde(tag)` change, a `rename_all` flip, or a timestamp format
275/// swap must surface here, not in the field.
276///
277/// Breaking the format is allowed in early beta, **deliberately**, update
278/// the fixture in the same commit so the diff documents the wire change.
279#[cfg(test)]
280mod schema_snapshot_tests {
281    use super::*;
282    use chrono::TimeZone;
283    use serde_json::json;
284
285    fn ts() -> DateTime<Utc> {
286        Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
287    }
288
289    fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
290        let actual = serde_json::to_value(fact).expect("serialize StepFact");
291        assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
292    }
293
294    #[test]
295    fn ceremony_started() {
296        assert_json(
297            &StepFact::CeremonyStarted {
298                name: "Root CA".to_string(),
299                started_at: ts(),
300            },
301            &json!({
302                "type": "ceremony_started",
303                "name": "Root CA",
304                "started_at": "2026-01-02T03:04:05Z",
305            }),
306        );
307    }
308
309    #[test]
310    fn act_started() {
311        assert_json(
312            &StepFact::ActStarted {
313                id: ActId::new("setup"),
314                label: "Setup".to_string(),
315            },
316            &json!({
317                "type": "act_started",
318                "id": "setup",
319                "label": "Setup",
320            }),
321        );
322    }
323
324    #[test]
325    fn step_started() {
326        assert_json(
327            &StepFact::StepStarted {
328                id: StepId::new("s1"),
329                label: "2.1".to_string(),
330                role: RoleId::new("crypto_officer"),
331                role_name: "Crypto Officer".to_string(),
332                started_at: ts(),
333            },
334            &json!({
335                "type": "step_started",
336                "id": "s1",
337                "label": "2.1",
338                "role": "crypto_officer",
339                "role_name": "Crypto Officer",
340                "started_at": "2026-01-02T03:04:05Z",
341            }),
342        );
343    }
344
345    #[test]
346    fn prompt_answered_confirm_bool() {
347        assert_json(
348            &StepFact::PromptAnswered {
349                step: Some(StepId::new("s1")),
350                prompt: Prompt::Confirm {
351                    question: "Proceed?".to_string(),
352                    default: Some(true),
353                },
354                response: ResponseRecord::Bool { value: true },
355                at: ts(),
356            },
357            &json!({
358                "type": "prompt_answered",
359                "step": "s1",
360                "prompt": { "type": "confirm", "question": "Proceed?", "default": true },
361                "response": { "type": "bool", "value": true },
362                "at": "2026-01-02T03:04:05Z",
363            }),
364        );
365    }
366
367    #[test]
368    fn prompt_answered_text_nonempty() {
369        assert_json(
370            &StepFact::PromptAnswered {
371                step: Some(StepId::new("s1")),
372                prompt: Prompt::Text {
373                    label: "Name".to_string(),
374                    validator: ValidatorSpec::NonEmpty,
375                },
376                response: ResponseRecord::Text {
377                    value: "Alice".to_string(),
378                },
379                at: ts(),
380            },
381            &json!({
382                "type": "prompt_answered",
383                "step": "s1",
384                "prompt": {
385                    "type": "text",
386                    "label": "Name",
387                    "validator": { "kind": "non_empty" },
388                },
389                "response": { "type": "text", "value": "Alice" },
390                "at": "2026-01-02T03:04:05Z",
391            }),
392        );
393    }
394
395    #[test]
396    fn prompt_answered_text_regex() {
397        assert_json(
398            &StepFact::PromptAnswered {
399                step: Some(StepId::new("s1")),
400                prompt: Prompt::Text {
401                    label: "SN".to_string(),
402                    validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
403                },
404                response: ResponseRecord::Text {
405                    value: "AB12".to_string(),
406                },
407                at: ts(),
408            },
409            &json!({
410                "type": "prompt_answered",
411                "step": "s1",
412                "prompt": {
413                    "type": "text",
414                    "label": "SN",
415                    "validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
416                },
417                "response": { "type": "text", "value": "AB12" },
418                "at": "2026-01-02T03:04:05Z",
419            }),
420        );
421    }
422
423    #[test]
424    fn prompt_answered_text_predefined() {
425        assert_json(
426            &StepFact::PromptAnswered {
427                step: Some(StepId::new("s1")),
428                prompt: Prompt::Text {
429                    label: "SN".to_string(),
430                    validator: ValidatorSpec::Predefined("serial_number".to_string()),
431                },
432                response: ResponseRecord::Text {
433                    value: "ABCD".to_string(),
434                },
435                at: ts(),
436            },
437            &json!({
438                "type": "prompt_answered",
439                "step": "s1",
440                "prompt": {
441                    "type": "text",
442                    "label": "SN",
443                    "validator": { "kind": "predefined", "value": "serial_number" },
444                },
445                "response": { "type": "text", "value": "ABCD" },
446                "at": "2026-01-02T03:04:05Z",
447            }),
448        );
449    }
450
451    #[test]
452    fn prompt_answered_secret_redacted() {
453        assert_json(
454            &StepFact::PromptAnswered {
455                step: Some(StepId::new("s1")),
456                prompt: Prompt::Secret {
457                    label: "PIN".to_string(),
458                },
459                response: ResponseRecord::SecretRedacted {
460                    sha256_of_plaintext: "f".repeat(64),
461                },
462                at: ts(),
463            },
464            &json!({
465                "type": "prompt_answered",
466                "step": "s1",
467                "prompt": { "type": "secret", "label": "PIN" },
468                "response": {
469                    "type": "secret_redacted",
470                    "sha256_of_plaintext": "f".repeat(64),
471                },
472                "at": "2026-01-02T03:04:05Z",
473            }),
474        );
475    }
476
477    #[test]
478    fn prompt_answered_literal_text() {
479        assert_json(
480            &StepFact::PromptAnswered {
481                step: Some(StepId::new("s1")),
482                prompt: Prompt::Literal {
483                    label: "Type 'attest'".to_string(),
484                    expected: "attest".to_string(),
485                },
486                response: ResponseRecord::Text {
487                    value: "attest".to_string(),
488                },
489                at: ts(),
490            },
491            &json!({
492                "type": "prompt_answered",
493                "step": "s1",
494                "prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
495                "response": { "type": "text", "value": "attest" },
496                "at": "2026-01-02T03:04:05Z",
497            }),
498        );
499    }
500
501    #[test]
502    fn prompt_answered_continue_acknowledged() {
503        assert_json(
504            &StepFact::PromptAnswered {
505                step: Some(StepId::new("s1")),
506                prompt: Prompt::Continue {
507                    hint: Some("Press Enter".to_string()),
508                },
509                response: ResponseRecord::Acknowledged,
510                at: ts(),
511            },
512            &json!({
513                "type": "prompt_answered",
514                "step": "s1",
515                "prompt": { "type": "continue", "hint": "Press Enter" },
516                "response": { "type": "acknowledged" },
517                "at": "2026-01-02T03:04:05Z",
518            }),
519        );
520    }
521
522    #[test]
523    fn backend_operation() {
524        assert_json(
525            &StepFact::BackendOperation {
526                step: StepId::new("s1"),
527                kind: "generate_keypair".to_string(),
528                inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
529                outputs: json!({ "key_id": "k1" }),
530                fingerprint: Some("sha256:deadbeef".to_string()),
531            },
532            &json!({
533                "type": "backend_operation",
534                "step": "s1",
535                "kind": "generate_keypair",
536                "inputs": { "algorithm": "rsa", "bits": 4096 },
537                "outputs": { "key_id": "k1" },
538                "fingerprint": "sha256:deadbeef",
539            }),
540        );
541    }
542
543    #[test]
544    fn attestation_recorded() {
545        assert_json(
546            &StepFact::AttestationRecorded {
547                step: StepId::new("s1"),
548                role: RoleId::new("crypto_officer"),
549                statement: "I confirm.".to_string(),
550                at: ts(),
551            },
552            &json!({
553                "type": "attestation_recorded",
554                "step": "s1",
555                "role": "crypto_officer",
556                "statement": "I confirm.",
557                "at": "2026-01-02T03:04:05Z",
558            }),
559        );
560    }
561
562    #[test]
563    fn artifact_written() {
564        assert_json(
565            &StepFact::ArtifactWritten {
566                step: StepId::new("s1"),
567                name: "root.crt".to_string(),
568                path: "/out/root.crt".into(),
569                sha256: "a".repeat(64),
570            },
571            &json!({
572                "type": "artifact_written",
573                "step": "s1",
574                "name": "root.crt",
575                "path": "/out/root.crt",
576                "sha256": "a".repeat(64),
577            }),
578        );
579    }
580
581    #[test]
582    fn deviation_recorded() {
583        assert_json(
584            &StepFact::DeviationRecorded {
585                step: Some(StepId::new("s1")),
586                text: "phone rang".to_string(),
587                at: ts(),
588            },
589            &json!({
590                "type": "deviation_recorded",
591                "step": "s1",
592                "text": "phone rang",
593                "at": "2026-01-02T03:04:05Z",
594            }),
595        );
596    }
597
598    #[test]
599    fn step_completed_completed() {
600        assert_json(
601            &StepFact::StepCompleted {
602                id: StepId::new("s1"),
603                outcome: StepOutcome::Completed {
604                    message: "done".to_string(),
605                },
606                completed_at: ts(),
607            },
608            &json!({
609                "type": "step_completed",
610                "id": "s1",
611                "outcome": { "status": "completed", "message": "done" },
612                "completed_at": "2026-01-02T03:04:05Z",
613            }),
614        );
615    }
616
617    #[test]
618    fn ceremony_completed() {
619        assert_json(
620            &StepFact::CeremonyCompleted { completed_at: ts() },
621            &json!({
622                "type": "ceremony_completed",
623                "completed_at": "2026-01-02T03:04:05Z",
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                failed_at: ts(),
634            },
635            &json!({
636                "type": "ceremony_failed",
637                "error": { "kind": "aborted", "message": "ceremony aborted by operator" },
638                "failed_at": "2026-01-02T03:04:05Z",
639            }),
640        );
641    }
642}