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}