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}