use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{Attestation, Event, EventId, EventSource, EventType};
pub const SCHEMA_MIGRATION_V1_TO_V2_TARGET: u16 = 2;
pub const SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND: &str = "schema_migration.v1_to_v2";
pub const SCHEMA_MIGRATION_V1_TO_V2_ID: &str = "v1_to_v2";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaMigrationV1ToV2Payload {
pub previous_v1_head_hash: String,
pub migration_script_digest: String,
pub schema_version_target: u16,
pub operator_attestation: Option<Attestation>,
pub fixture_verification_result_hash: String,
}
impl SchemaMigrationV1ToV2Payload {
#[must_use]
pub fn new(
previous_v1_head_hash: impl Into<String>,
migration_script_digest: impl Into<String>,
operator_attestation: Option<Attestation>,
fixture_verification_result_hash: impl Into<String>,
) -> Self {
Self {
previous_v1_head_hash: previous_v1_head_hash.into(),
migration_script_digest: migration_script_digest.into(),
schema_version_target: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
operator_attestation,
fixture_verification_result_hash: fixture_verification_result_hash.into(),
}
}
pub fn validate(&self) -> Result<(), SchemaMigrationPayloadError> {
if self.schema_version_target != SCHEMA_MIGRATION_V1_TO_V2_TARGET {
return Err(SchemaMigrationPayloadError::WrongTarget {
found: self.schema_version_target,
expected: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
});
}
if self.previous_v1_head_hash.trim().is_empty() {
return Err(SchemaMigrationPayloadError::MissingPreviousHeadHash);
}
if self.migration_script_digest.trim().is_empty() {
return Err(SchemaMigrationPayloadError::MissingMigrationScriptDigest);
}
if self.fixture_verification_result_hash.trim().is_empty() {
return Err(SchemaMigrationPayloadError::MissingFixtureVerificationResultHash);
}
Ok(())
}
}
pub fn schema_migration_v1_to_v2_event(
payload: SchemaMigrationV1ToV2Payload,
observed_at: DateTime<Utc>,
recorded_at: DateTime<Utc>,
session_id: Option<String>,
) -> Result<Event, SchemaMigrationEventError> {
payload.validate()?;
Ok(Event {
id: EventId::new(),
schema_version: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
observed_at,
recorded_at,
source: EventSource::Runtime,
event_type: EventType::SystemNote,
trace_id: None,
session_id,
domain_tags: vec!["schema".into(), "migration".into(), "v2".into()],
payload: serde_json::json!({
"kind": SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND,
"migration_id": SCHEMA_MIGRATION_V1_TO_V2_ID,
"payload": payload,
}),
payload_hash: String::new(),
prev_event_hash: None,
event_hash: String::new(),
})
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaMigrationEventError {
#[error("{0}")]
Payload(#[from] SchemaMigrationPayloadError),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaMigrationPayloadError {
#[error("schema migration payload targets {found}; expected {expected}")]
WrongTarget {
found: u16,
expected: u16,
},
#[error("schema migration payload is missing previous_v1_head_hash")]
MissingPreviousHeadHash,
#[error("schema migration payload is missing migration_script_digest")]
MissingMigrationScriptDigest,
#[error("schema migration payload is missing fixture_verification_result_hash")]
MissingFixtureVerificationResultHash,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_migration_v1_to_v2_payload_wire_shape_is_stable() {
let payload =
SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
let json = serde_json::to_value(&payload).expect("payload serializes");
assert_eq!(json["previous_v1_head_hash"], "head-hash");
assert_eq!(json["migration_script_digest"], "script-digest");
assert_eq!(json["schema_version_target"], 2);
assert_eq!(json["operator_attestation"], serde_json::Value::Null);
assert_eq!(json["fixture_verification_result_hash"], "fixture-digest");
let decoded: SchemaMigrationV1ToV2Payload =
serde_json::from_value(json).expect("payload deserializes");
assert_eq!(decoded, payload);
}
#[test]
fn schema_migration_v1_to_v2_payload_validates_required_fields() {
let payload =
SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
assert_eq!(payload.validate(), Ok(()));
let mut wrong_target = payload.clone();
wrong_target.schema_version_target = 3;
assert_eq!(
wrong_target.validate(),
Err(SchemaMigrationPayloadError::WrongTarget {
found: 3,
expected: 2,
})
);
let mut missing_head = payload.clone();
missing_head.previous_v1_head_hash.clear();
assert_eq!(
missing_head.validate(),
Err(SchemaMigrationPayloadError::MissingPreviousHeadHash)
);
let mut missing_script = payload.clone();
missing_script.migration_script_digest.clear();
assert_eq!(
missing_script.validate(),
Err(SchemaMigrationPayloadError::MissingMigrationScriptDigest)
);
let mut missing_fixture = payload;
missing_fixture.fixture_verification_result_hash.clear();
assert_eq!(
missing_fixture.validate(),
Err(SchemaMigrationPayloadError::MissingFixtureVerificationResultHash)
);
}
#[test]
fn schema_migration_v1_to_v2_event_carries_typed_payload() {
let payload =
SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
let event = schema_migration_v1_to_v2_event(
payload,
"2026-05-04T13:00:00Z".parse().unwrap(),
"2026-05-04T13:00:01Z".parse().unwrap(),
Some("s2-migration".into()),
)
.expect("boundary event builds");
assert_eq!(event.schema_version, SCHEMA_MIGRATION_V1_TO_V2_TARGET);
assert_eq!(event.source, EventSource::Runtime);
assert_eq!(event.event_type, EventType::SystemNote);
assert_eq!(event.session_id.as_deref(), Some("s2-migration"));
assert_eq!(event.payload["kind"], SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND);
assert_eq!(event.payload["migration_id"], SCHEMA_MIGRATION_V1_TO_V2_ID);
assert_eq!(
event.payload["payload"]["previous_v1_head_hash"],
"head-hash"
);
assert_eq!(event.payload_hash, "");
assert_eq!(event.prev_event_hash, None);
assert_eq!(event.event_hash, "");
}
#[test]
fn schema_migration_v1_to_v2_event_rejects_invalid_payload() {
let mut payload =
SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
payload.previous_v1_head_hash.clear();
let err = schema_migration_v1_to_v2_event(
payload,
"2026-05-04T13:00:00Z".parse().unwrap(),
"2026-05-04T13:00:01Z".parse().unwrap(),
None,
)
.expect_err("invalid payload must fail");
assert_eq!(
err,
SchemaMigrationEventError::Payload(
SchemaMigrationPayloadError::MissingPreviousHeadHash
)
);
}
}