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}