1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::{Attestation, Event, EventId, EventSource, EventType};
10
11pub const SCHEMA_MIGRATION_V1_TO_V2_TARGET: u16 = 2;
13
14pub const SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND: &str = "schema_migration.v1_to_v2";
16
17pub const SCHEMA_MIGRATION_V1_TO_V2_ID: &str = "v1_to_v2";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct SchemaMigrationV1ToV2Payload {
24 pub previous_v1_head_hash: String,
26 pub migration_script_digest: String,
28 pub schema_version_target: u16,
30 pub operator_attestation: Option<Attestation>,
32 pub fixture_verification_result_hash: String,
34}
35
36impl SchemaMigrationV1ToV2Payload {
37 #[must_use]
39 pub fn new(
40 previous_v1_head_hash: impl Into<String>,
41 migration_script_digest: impl Into<String>,
42 operator_attestation: Option<Attestation>,
43 fixture_verification_result_hash: impl Into<String>,
44 ) -> Self {
45 Self {
46 previous_v1_head_hash: previous_v1_head_hash.into(),
47 migration_script_digest: migration_script_digest.into(),
48 schema_version_target: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
49 operator_attestation,
50 fixture_verification_result_hash: fixture_verification_result_hash.into(),
51 }
52 }
53
54 pub fn validate(&self) -> Result<(), SchemaMigrationPayloadError> {
56 if self.schema_version_target != SCHEMA_MIGRATION_V1_TO_V2_TARGET {
57 return Err(SchemaMigrationPayloadError::WrongTarget {
58 found: self.schema_version_target,
59 expected: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
60 });
61 }
62 if self.previous_v1_head_hash.trim().is_empty() {
63 return Err(SchemaMigrationPayloadError::MissingPreviousHeadHash);
64 }
65 if self.migration_script_digest.trim().is_empty() {
66 return Err(SchemaMigrationPayloadError::MissingMigrationScriptDigest);
67 }
68 if self.fixture_verification_result_hash.trim().is_empty() {
69 return Err(SchemaMigrationPayloadError::MissingFixtureVerificationResultHash);
70 }
71 Ok(())
72 }
73}
74
75pub fn schema_migration_v1_to_v2_event(
82 payload: SchemaMigrationV1ToV2Payload,
83 observed_at: DateTime<Utc>,
84 recorded_at: DateTime<Utc>,
85 session_id: Option<String>,
86) -> Result<Event, SchemaMigrationEventError> {
87 payload.validate()?;
88
89 Ok(Event {
90 id: EventId::new(),
91 schema_version: SCHEMA_MIGRATION_V1_TO_V2_TARGET,
92 observed_at,
93 recorded_at,
94 source: EventSource::Runtime,
95 event_type: EventType::SystemNote,
96 trace_id: None,
97 session_id,
98 domain_tags: vec!["schema".into(), "migration".into(), "v2".into()],
99 payload: serde_json::json!({
100 "kind": SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND,
101 "migration_id": SCHEMA_MIGRATION_V1_TO_V2_ID,
102 "payload": payload,
103 }),
104 payload_hash: String::new(),
105 prev_event_hash: None,
106 event_hash: String::new(),
107 })
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum SchemaMigrationEventError {
113 #[error("{0}")]
115 Payload(#[from] SchemaMigrationPayloadError),
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum SchemaMigrationPayloadError {
121 #[error("schema migration payload targets {found}; expected {expected}")]
123 WrongTarget {
124 found: u16,
126 expected: u16,
128 },
129 #[error("schema migration payload is missing previous_v1_head_hash")]
131 MissingPreviousHeadHash,
132 #[error("schema migration payload is missing migration_script_digest")]
134 MissingMigrationScriptDigest,
135 #[error("schema migration payload is missing fixture_verification_result_hash")]
137 MissingFixtureVerificationResultHash,
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn schema_migration_v1_to_v2_payload_wire_shape_is_stable() {
146 let payload =
147 SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
148
149 let json = serde_json::to_value(&payload).expect("payload serializes");
150
151 assert_eq!(json["previous_v1_head_hash"], "head-hash");
152 assert_eq!(json["migration_script_digest"], "script-digest");
153 assert_eq!(json["schema_version_target"], 2);
154 assert_eq!(json["operator_attestation"], serde_json::Value::Null);
155 assert_eq!(json["fixture_verification_result_hash"], "fixture-digest");
156 let decoded: SchemaMigrationV1ToV2Payload =
157 serde_json::from_value(json).expect("payload deserializes");
158 assert_eq!(decoded, payload);
159 }
160
161 #[test]
162 fn schema_migration_v1_to_v2_payload_validates_required_fields() {
163 let payload =
164 SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
165 assert_eq!(payload.validate(), Ok(()));
166
167 let mut wrong_target = payload.clone();
168 wrong_target.schema_version_target = 3;
169 assert_eq!(
170 wrong_target.validate(),
171 Err(SchemaMigrationPayloadError::WrongTarget {
172 found: 3,
173 expected: 2,
174 })
175 );
176
177 let mut missing_head = payload.clone();
178 missing_head.previous_v1_head_hash.clear();
179 assert_eq!(
180 missing_head.validate(),
181 Err(SchemaMigrationPayloadError::MissingPreviousHeadHash)
182 );
183
184 let mut missing_script = payload.clone();
185 missing_script.migration_script_digest.clear();
186 assert_eq!(
187 missing_script.validate(),
188 Err(SchemaMigrationPayloadError::MissingMigrationScriptDigest)
189 );
190
191 let mut missing_fixture = payload;
192 missing_fixture.fixture_verification_result_hash.clear();
193 assert_eq!(
194 missing_fixture.validate(),
195 Err(SchemaMigrationPayloadError::MissingFixtureVerificationResultHash)
196 );
197 }
198
199 #[test]
200 fn schema_migration_v1_to_v2_event_carries_typed_payload() {
201 let payload =
202 SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
203
204 let event = schema_migration_v1_to_v2_event(
205 payload,
206 "2026-05-04T13:00:00Z".parse().unwrap(),
207 "2026-05-04T13:00:01Z".parse().unwrap(),
208 Some("s2-migration".into()),
209 )
210 .expect("boundary event builds");
211
212 assert_eq!(event.schema_version, SCHEMA_MIGRATION_V1_TO_V2_TARGET);
213 assert_eq!(event.source, EventSource::Runtime);
214 assert_eq!(event.event_type, EventType::SystemNote);
215 assert_eq!(event.session_id.as_deref(), Some("s2-migration"));
216 assert_eq!(event.payload["kind"], SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND);
217 assert_eq!(event.payload["migration_id"], SCHEMA_MIGRATION_V1_TO_V2_ID);
218 assert_eq!(
219 event.payload["payload"]["previous_v1_head_hash"],
220 "head-hash"
221 );
222 assert_eq!(event.payload_hash, "");
223 assert_eq!(event.prev_event_hash, None);
224 assert_eq!(event.event_hash, "");
225 }
226
227 #[test]
228 fn schema_migration_v1_to_v2_event_rejects_invalid_payload() {
229 let mut payload =
230 SchemaMigrationV1ToV2Payload::new("head-hash", "script-digest", None, "fixture-digest");
231 payload.previous_v1_head_hash.clear();
232
233 let err = schema_migration_v1_to_v2_event(
234 payload,
235 "2026-05-04T13:00:00Z".parse().unwrap(),
236 "2026-05-04T13:00:01Z".parse().unwrap(),
237 None,
238 )
239 .expect_err("invalid payload must fail");
240
241 assert_eq!(
242 err,
243 SchemaMigrationEventError::Payload(
244 SchemaMigrationPayloadError::MissingPreviousHeadHash
245 )
246 );
247 }
248}