Skip to main content

cortex_core/
schema_migration.rs

1//! Typed payloads for schema migration boundary events.
2//!
3//! These payloads are data contracts only. Emitting them into the ledger is
4//! owned by the Lane S2 migration command and must not happen implicitly.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::{Attestation, Event, EventId, EventSource, EventType};
10
11/// Target schema version for the v1 -> v2 cutover.
12pub const SCHEMA_MIGRATION_V1_TO_V2_TARGET: u16 = 2;
13
14/// Payload discriminator for the v1 -> v2 migration boundary event.
15pub const SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND: &str = "schema_migration.v1_to_v2";
16
17/// Stable migration id for the v1 -> v2 cutover recipe.
18pub const SCHEMA_MIGRATION_V1_TO_V2_ID: &str = "v1_to_v2";
19
20/// Payload for the `schema_migration.v1_to_v2` boundary event required by
21/// ADR 0018.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct SchemaMigrationV1ToV2Payload {
24    /// Cryptographic tie to the pre-migration v1 chain tip.
25    pub previous_v1_head_hash: String,
26    /// Digest of the applied SQL and migration tooling bundle.
27    pub migration_script_digest: String,
28    /// Explicit target schema version. Must be `2` for this payload type.
29    pub schema_version_target: u16,
30    /// Operator attestation over the migration payload summary, when present.
31    pub operator_attestation: Option<Attestation>,
32    /// Digest of the fixture verification transcript for this migration build.
33    pub fixture_verification_result_hash: String,
34}
35
36impl SchemaMigrationV1ToV2Payload {
37    /// Construct a v1 -> v2 migration payload with the fixed target version.
38    #[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    /// Validate the boundary payload before it is embedded in an event.
55    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
75/// Build the typed `schema_migration.v1_to_v2` boundary event without
76/// appending it to any ledger.
77///
78/// The event uses `EventType::SystemNote` in the current pre-cutover schema
79/// and carries the migration kind in the payload. This avoids adding a new
80/// `EventType` wire string before the coordinated schema v2 cutover.
81pub 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/// Boundary event construction failures.
111#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum SchemaMigrationEventError {
113    /// The typed payload is invalid.
114    #[error("{0}")]
115    Payload(#[from] SchemaMigrationPayloadError),
116}
117
118/// Payload validation failures for schema migration boundary events.
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum SchemaMigrationPayloadError {
121    /// The payload targets a schema version other than v2.
122    #[error("schema migration payload targets {found}; expected {expected}")]
123    WrongTarget {
124        /// Observed target schema version.
125        found: u16,
126        /// Required target schema version.
127        expected: u16,
128    },
129    /// The previous v1 chain head hash is absent.
130    #[error("schema migration payload is missing previous_v1_head_hash")]
131    MissingPreviousHeadHash,
132    /// The SQL/tooling digest is absent.
133    #[error("schema migration payload is missing migration_script_digest")]
134    MissingMigrationScriptDigest,
135    /// The fixture verification transcript digest is absent.
136    #[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}