use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ir::{ActId, RoleId, StepId};
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum ValidatorSpec {
NonEmpty,
Regex(String),
Predefined(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Prompt {
Confirm {
question: String,
default: Option<bool>,
},
Text {
label: String,
validator: ValidatorSpec,
},
Secret {
label: String,
},
Literal {
label: String,
expected: String,
},
Continue {
hint: Option<String>,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseRecord {
Bool {
value: bool,
},
Text {
value: String,
},
SecretRedacted {
sha256_of_plaintext: String,
},
Acknowledged,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorRecord {
pub kind: String,
pub message: String,
}
impl ErrorRecord {
pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum StepOutcome {
Completed {
message: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StepFact {
CeremonyStarted {
name: String,
started_at: DateTime<Utc>,
},
ActStarted {
id: ActId,
label: String,
},
StepStarted {
id: StepId,
label: String,
role: RoleId,
role_name: String,
started_at: DateTime<Utc>,
},
PromptAnswered {
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<StepId>,
prompt: Prompt,
response: ResponseRecord,
at: DateTime<Utc>,
},
BackendOperation {
step: StepId,
kind: String,
inputs: serde_json::Value,
outputs: serde_json::Value,
fingerprint: Option<String>,
},
AttestationRecorded {
step: StepId,
role: RoleId,
statement: String,
at: DateTime<Utc>,
},
ArtifactWritten {
step: StepId,
name: String,
path: PathBuf,
sha256: String,
},
DeviationRecorded {
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<StepId>,
text: String,
at: DateTime<Utc>,
},
StepCompleted {
id: StepId,
outcome: StepOutcome,
completed_at: DateTime<Utc>,
},
CeremonyCompleted {
completed_at: DateTime<Utc>,
},
CeremonyFailed {
error: ErrorRecord,
failed_at: DateTime<Utc>,
},
}
#[cfg(test)]
mod schema_snapshot_tests {
use super::*;
use chrono::TimeZone;
use serde_json::json;
fn ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
}
fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
let actual = serde_json::to_value(fact).expect("serialize StepFact");
assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
}
#[test]
fn ceremony_started() {
assert_json(
&StepFact::CeremonyStarted {
name: "Root CA".to_string(),
started_at: ts(),
},
&json!({
"type": "ceremony_started",
"name": "Root CA",
"started_at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn act_started() {
assert_json(
&StepFact::ActStarted {
id: ActId::new("setup"),
label: "Setup".to_string(),
},
&json!({
"type": "act_started",
"id": "setup",
"label": "Setup",
}),
);
}
#[test]
fn step_started() {
assert_json(
&StepFact::StepStarted {
id: StepId::new("s1"),
label: "2.1".to_string(),
role: RoleId::new("crypto_officer"),
role_name: "Crypto Officer".to_string(),
started_at: ts(),
},
&json!({
"type": "step_started",
"id": "s1",
"label": "2.1",
"role": "crypto_officer",
"role_name": "Crypto Officer",
"started_at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_confirm_bool() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Confirm {
question: "Proceed?".to_string(),
default: Some(true),
},
response: ResponseRecord::Bool { value: true },
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "confirm", "question": "Proceed?", "default": true },
"response": { "type": "bool", "value": true },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_text_nonempty() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "Name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
response: ResponseRecord::Text {
value: "Alice".to_string(),
},
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "Name",
"validator": { "kind": "non_empty" },
},
"response": { "type": "text", "value": "Alice" },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_text_regex() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
},
response: ResponseRecord::Text {
value: "AB12".to_string(),
},
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
},
"response": { "type": "text", "value": "AB12" },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_text_predefined() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Predefined("serial_number".to_string()),
},
response: ResponseRecord::Text {
value: "ABCD".to_string(),
},
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "predefined", "value": "serial_number" },
},
"response": { "type": "text", "value": "ABCD" },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_secret_redacted() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Secret {
label: "PIN".to_string(),
},
response: ResponseRecord::SecretRedacted {
sha256_of_plaintext: "f".repeat(64),
},
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "secret", "label": "PIN" },
"response": {
"type": "secret_redacted",
"sha256_of_plaintext": "f".repeat(64),
},
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_literal_text() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Literal {
label: "Type 'attest'".to_string(),
expected: "attest".to_string(),
},
response: ResponseRecord::Text {
value: "attest".to_string(),
},
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
"response": { "type": "text", "value": "attest" },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn prompt_answered_continue_acknowledged() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Continue {
hint: Some("Press Enter".to_string()),
},
response: ResponseRecord::Acknowledged,
at: ts(),
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "continue", "hint": "Press Enter" },
"response": { "type": "acknowledged" },
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn backend_operation() {
assert_json(
&StepFact::BackendOperation {
step: StepId::new("s1"),
kind: "generate_keypair".to_string(),
inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
outputs: json!({ "key_id": "k1" }),
fingerprint: Some("sha256:deadbeef".to_string()),
},
&json!({
"type": "backend_operation",
"step": "s1",
"kind": "generate_keypair",
"inputs": { "algorithm": "rsa", "bits": 4096 },
"outputs": { "key_id": "k1" },
"fingerprint": "sha256:deadbeef",
}),
);
}
#[test]
fn attestation_recorded() {
assert_json(
&StepFact::AttestationRecorded {
step: StepId::new("s1"),
role: RoleId::new("crypto_officer"),
statement: "I confirm.".to_string(),
at: ts(),
},
&json!({
"type": "attestation_recorded",
"step": "s1",
"role": "crypto_officer",
"statement": "I confirm.",
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn artifact_written() {
assert_json(
&StepFact::ArtifactWritten {
step: StepId::new("s1"),
name: "root.crt".to_string(),
path: "/out/root.crt".into(),
sha256: "a".repeat(64),
},
&json!({
"type": "artifact_written",
"step": "s1",
"name": "root.crt",
"path": "/out/root.crt",
"sha256": "a".repeat(64),
}),
);
}
#[test]
fn deviation_recorded() {
assert_json(
&StepFact::DeviationRecorded {
step: Some(StepId::new("s1")),
text: "phone rang".to_string(),
at: ts(),
},
&json!({
"type": "deviation_recorded",
"step": "s1",
"text": "phone rang",
"at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn step_completed_completed() {
assert_json(
&StepFact::StepCompleted {
id: StepId::new("s1"),
outcome: StepOutcome::Completed {
message: "done".to_string(),
},
completed_at: ts(),
},
&json!({
"type": "step_completed",
"id": "s1",
"outcome": { "status": "completed", "message": "done" },
"completed_at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn ceremony_completed() {
assert_json(
&StepFact::CeremonyCompleted { completed_at: ts() },
&json!({
"type": "ceremony_completed",
"completed_at": "2026-01-02T03:04:05Z",
}),
);
}
#[test]
fn ceremony_failed() {
assert_json(
&StepFact::CeremonyFailed {
error: ErrorRecord::new("aborted", "ceremony aborted by operator"),
failed_at: ts(),
},
&json!({
"type": "ceremony_failed",
"error": { "kind": "aborted", "message": "ceremony aborted by operator" },
"failed_at": "2026-01-02T03:04:05Z",
}),
);
}
}