crtx-core 0.1.1

Core IDs, errors, and schema constants for Cortex.
Documentation
//! Typed payloads for schema migration boundary events.
//!
//! These payloads are data contracts only. Emitting them into the ledger is
//! owned by the Lane S2 migration command and must not happen implicitly.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::{Attestation, Event, EventId, EventSource, EventType};

/// Target schema version for the v1 -> v2 cutover.
pub const SCHEMA_MIGRATION_V1_TO_V2_TARGET: u16 = 2;

/// Payload discriminator for the v1 -> v2 migration boundary event.
pub const SCHEMA_MIGRATION_V1_TO_V2_EVENT_KIND: &str = "schema_migration.v1_to_v2";

/// Stable migration id for the v1 -> v2 cutover recipe.
pub const SCHEMA_MIGRATION_V1_TO_V2_ID: &str = "v1_to_v2";

/// Payload for the `schema_migration.v1_to_v2` boundary event required by
/// ADR 0018.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaMigrationV1ToV2Payload {
    /// Cryptographic tie to the pre-migration v1 chain tip.
    pub previous_v1_head_hash: String,
    /// Digest of the applied SQL and migration tooling bundle.
    pub migration_script_digest: String,
    /// Explicit target schema version. Must be `2` for this payload type.
    pub schema_version_target: u16,
    /// Operator attestation over the migration payload summary, when present.
    pub operator_attestation: Option<Attestation>,
    /// Digest of the fixture verification transcript for this migration build.
    pub fixture_verification_result_hash: String,
}

impl SchemaMigrationV1ToV2Payload {
    /// Construct a v1 -> v2 migration payload with the fixed target version.
    #[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(),
        }
    }

    /// Validate the boundary payload before it is embedded in an event.
    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(())
    }
}

/// Build the typed `schema_migration.v1_to_v2` boundary event without
/// appending it to any ledger.
///
/// The event uses `EventType::SystemNote` in the current pre-cutover schema
/// and carries the migration kind in the payload. This avoids adding a new
/// `EventType` wire string before the coordinated schema v2 cutover.
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(),
    })
}

/// Boundary event construction failures.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaMigrationEventError {
    /// The typed payload is invalid.
    #[error("{0}")]
    Payload(#[from] SchemaMigrationPayloadError),
}

/// Payload validation failures for schema migration boundary events.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaMigrationPayloadError {
    /// The payload targets a schema version other than v2.
    #[error("schema migration payload targets {found}; expected {expected}")]
    WrongTarget {
        /// Observed target schema version.
        found: u16,
        /// Required target schema version.
        expected: u16,
    },
    /// The previous v1 chain head hash is absent.
    #[error("schema migration payload is missing previous_v1_head_hash")]
    MissingPreviousHeadHash,
    /// The SQL/tooling digest is absent.
    #[error("schema migration payload is missing migration_script_digest")]
    MissingMigrationScriptDigest,
    /// The fixture verification transcript digest is absent.
    #[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
            )
        );
    }
}