Skip to main content

type_bridge_contract/
migration.rs

1//! Context-free canonical migration identities, fingerprints, and schema steps.
2
3use std::fmt;
4
5use serde::{Serialize, Serializer};
6use sha2::{Digest as _, Sha256};
7
8use crate::capability::{CapabilityId, CapabilitySet};
9use crate::codec::to_canonical_json;
10use crate::diagnostic::{Diagnostic, DiagnosticCategory};
11use crate::fingerprint::{
12    CanonicalizationVersion, Fingerprint, FingerprintDigest, FingerprintDomain,
13};
14use crate::migration_assertion::{
15    AssertionExpectation, MigrationAssertionPlan, MigrationAssertionPlanFingerprint,
16};
17use crate::migration_backfill::AttributeBackfillPlan;
18use crate::schema_delta::{SchemaDelta, SchemaOperation};
19use crate::schema_fingerprint::ManagedSemanticSchemaFingerprint;
20
21/// Exact canonical migration format discriminator.
22pub const MIGRATION_FORMAT_V1: &str = "typebridge.migration/v1";
23// Pre-release wire ledger: migration/v1 gained assertion steps and the manifest
24// lowering-profile binding while legacy schema-only canonical bytes stay unchanged.
25/// Fingerprint domain for compound migration ledger keys.
26pub const MIGRATION_ID_FINGERPRINT_DOMAIN: &str = "typebridge.migration.id";
27/// Canonicalization contract for compound migration IDs.
28pub const MIGRATION_ID_CANONICALIZATION: &str = "typebridge.migration-id/v1";
29/// Fingerprint domain for trusted schema-delta bytes.
30pub const SCHEMA_DELTA_FINGERPRINT_DOMAIN: &str = "typebridge.migration.schema-delta";
31/// Canonicalization contract for trusted schema deltas.
32pub const SCHEMA_DELTA_FINGERPRINT_CANONICALIZATION: &str = "typebridge.schema-delta/v1";
33/// Fingerprint domain for ordered schema-only migration plans.
34pub const MIGRATION_PLAN_FINGERPRINT_DOMAIN: &str = "typebridge.migration.plan";
35/// Canonicalization contract for ordered schema-only migration plans.
36pub const MIGRATION_PLAN_FINGERPRINT_CANONICALIZATION: &str = "typebridge.migration-plan/v1";
37/// Capability required by a persisted verifier-derived conditional assertion.
38pub const CONDITIONAL_RESOLUTION_CAPABILITY: &str = "migration.conditional-resolution";
39/// Maximum ASCII byte length of one portable migration identity component.
40pub const MAX_MIGRATION_COMPONENT_BYTES: usize = 255;
41
42fn validate_component(
43    value: String,
44    kind: &'static str,
45    allow_leading_digit: bool,
46) -> Result<String, Diagnostic> {
47    let mut bytes = value.bytes();
48    let valid_first = bytes.next().is_some_and(|byte| {
49        byte.is_ascii_lowercase() || (allow_leading_digit && byte.is_ascii_digit())
50    });
51    let valid_rest = bytes.all(|byte| {
52        byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
53    });
54    if value.len() <= MAX_MIGRATION_COMPONENT_BYTES && valid_first && valid_rest {
55        Ok(value)
56    } else {
57        Err(Diagnostic::stable(
58            DiagnosticCategory::InvalidContract,
59            "invalid_migration_identity_component",
60            "migration identity component must be bounded portable lowercase ASCII",
61        )
62        .with_detail("component_kind", kind))
63    }
64}
65
66macro_rules! migration_component {
67    ($name:ident, $doc:literal, $kind:literal, $leading_digit:expr) => {
68        #[doc = $doc]
69        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
70        #[serde(transparent)]
71        pub struct $name(String);
72
73        impl $name {
74            /// Validate and construct this portable migration identity component.
75            pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
76                Ok(Self(validate_component(
77                    value.into(),
78                    $kind,
79                    $leading_digit,
80                )?))
81            }
82
83            /// Return the canonical component spelling.
84            pub fn as_str(&self) -> &str {
85                &self.0
86            }
87        }
88
89        impl fmt::Display for $name {
90            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91                formatter.write_str(self.as_str())
92            }
93        }
94    };
95}
96
97migration_component!(
98    MigrationAppLabel,
99    "A portable application label forming the first component of migration identity.",
100    "app_label",
101    false
102);
103migration_component!(
104    MigrationName,
105    "A portable filename-stem migration name forming the second identity component.",
106    "name",
107    true
108);
109migration_component!(
110    MigrationStepId,
111    "A stable identity unique within one ordered migration plan.",
112    "step_id",
113    false
114);
115
116/// A typed compound migration identity; it is never delimiter-joined.
117#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
118pub struct MigrationId {
119    app_label: MigrationAppLabel,
120    name: MigrationName,
121}
122
123impl MigrationId {
124    /// Validate and construct a compound migration identity.
125    pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Result<Self, Diagnostic> {
126        Ok(Self {
127            app_label: MigrationAppLabel::new(app_label)?,
128            name: MigrationName::new(name)?,
129        })
130    }
131
132    /// Construct from already validated components.
133    #[must_use]
134    pub const fn from_components(app_label: MigrationAppLabel, name: MigrationName) -> Self {
135        Self { app_label, name }
136    }
137
138    /// Return the application-label component.
139    pub const fn app_label(&self) -> &MigrationAppLabel {
140        &self.app_label
141    }
142
143    /// Return the filename-stem name component.
144    pub const fn name(&self) -> &MigrationName {
145        &self.name
146    }
147
148    /// Return exact canonical compound-ID bytes.
149    pub fn canonical_bytes(&self) -> Result<Vec<u8>, Diagnostic> {
150        to_canonical_json(self)
151    }
152
153    /// Derive the domain-separated ledger key for this compound identity.
154    pub fn ledger_key(&self) -> Result<MigrationLedgerKey, Diagnostic> {
155        MigrationLedgerKey::compute(self)
156    }
157}
158
159/// The closed canonical migration format admitted by this contract revision.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
161pub struct MigrationFormat;
162
163impl MigrationFormat {
164    /// The initial canonical migration format.
165    pub const V1: Self = Self;
166
167    /// Validate an exact migration format discriminator.
168    pub fn new(value: &str) -> Result<Self, Diagnostic> {
169        if value == MIGRATION_FORMAT_V1 {
170            Ok(Self::V1)
171        } else {
172            Err(Diagnostic::stable(
173                DiagnosticCategory::InvalidContract,
174                "unsupported_migration_format",
175                "canonical migration format is not supported",
176            )
177            .with_detail("actual", value.to_owned())
178            .with_detail("supported", MIGRATION_FORMAT_V1))
179        }
180    }
181
182    /// Return the exact wire discriminator.
183    pub const fn as_str(self) -> &'static str {
184        MIGRATION_FORMAT_V1
185    }
186}
187
188impl Serialize for MigrationFormat {
189    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
190    where
191        S: Serializer,
192    {
193        serializer.serialize_str(self.as_str())
194    }
195}
196
197/// Retry policy supported by the schema-only contract slice.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum RetryPolicy {
201    /// Never replay without a higher-layer verified recovery decision.
202    Never,
203}
204
205/// Recovery policy supported by the schema-only contract slice.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
207#[serde(rename_all = "snake_case")]
208pub enum RecoveryPolicy {
209    /// Stop and require an explicit verified operator decision.
210    OperatorRequired,
211}
212
213/// Raw full SHA-256 of exact canonical manifest bytes, stored outside the manifest.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
215#[serde(transparent)]
216pub struct MigrationManifestDigest(FingerprintDigest);
217
218impl MigrationManifestDigest {
219    /// Hash exact canonical manifest bytes without adding a fingerprint domain preimage.
220    #[must_use]
221    pub fn compute(canonical_manifest_bytes: &[u8]) -> Self {
222        let digest = Sha256::digest(canonical_manifest_bytes);
223        let hex = digest
224            .iter()
225            .map(|byte| format!("{byte:02x}"))
226            .collect::<String>();
227        Self(
228            FingerprintDigest::from_hex(&hex)
229                .expect("SHA-256 always produces a valid lowercase 32-byte digest"),
230        )
231    }
232
233    /// Parse exactly 64 lowercase hexadecimal characters.
234    pub fn from_hex(value: &str) -> Result<Self, Diagnostic> {
235        FingerprintDigest::from_hex(value).map(Self)
236    }
237
238    /// Return lowercase hexadecimal text.
239    #[must_use]
240    pub fn to_hex(self) -> String {
241        self.0.to_hex()
242    }
243
244    /// Return the raw 32 digest bytes.
245    #[must_use]
246    pub const fn bytes(self) -> [u8; 32] {
247        self.0.bytes()
248    }
249}
250
251/// Domain-separated ledger key derived from canonical compound migration-ID bytes.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
253#[serde(transparent)]
254pub struct MigrationLedgerKey(Fingerprint);
255
256impl MigrationLedgerKey {
257    /// Compute one stable ledger key without flattening identity components.
258    pub fn compute(id: &MigrationId) -> Result<Self, Diagnostic> {
259        Ok(Self(Fingerprint::compute(
260            FingerprintDomain::new(MIGRATION_ID_FINGERPRINT_DOMAIN)?,
261            CanonicalizationVersion::new(MIGRATION_ID_CANONICALIZATION)?,
262            None,
263            &id.canonical_bytes()?,
264        )))
265    }
266
267    /// Return the generic domain-separated fingerprint.
268    pub const fn as_fingerprint(&self) -> &Fingerprint {
269        &self.0
270    }
271}
272
273/// Fingerprint of one trusted schema delta's exact canonical bytes.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
275#[serde(transparent)]
276pub struct SchemaDeltaFingerprint(Fingerprint);
277
278impl SchemaDeltaFingerprint {
279    /// Compute the fingerprint of one already validated schema delta.
280    pub fn compute(delta: &SchemaDelta) -> Result<Self, Diagnostic> {
281        Ok(Self(Fingerprint::compute(
282            FingerprintDomain::new(SCHEMA_DELTA_FINGERPRINT_DOMAIN)?,
283            CanonicalizationVersion::new(SCHEMA_DELTA_FINGERPRINT_CANONICALIZATION)?,
284            None,
285            &delta.canonical_bytes()?,
286        )))
287    }
288
289    /// Return the generic domain-separated fingerprint.
290    pub const fn as_fingerprint(&self) -> &Fingerprint {
291        &self.0
292    }
293}
294
295/// The only context-free migration step kind in the schema-only slice.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
297#[serde(rename_all = "snake_case")]
298pub enum MigrationStepKind {
299    /// One trusted schema-delta chunk.
300    SchemaDelta,
301    /// One canonical verifier-derived no-rows assertion.
302    Assertion,
303    /// One closed binding-neutral data backfill.
304    Backfill,
305}
306
307/// Context-free claims derived exactly from one closed backfill plan.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct BackfillStepContract {
310    id: MigrationStepId,
311    plan_fingerprint: Fingerprint,
312    recovery: RecoveryPolicy,
313    required_capabilities: CapabilitySet,
314    retry: RetryPolicy,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    reverse: Option<crate::migration_backfill::BackfillReverseProgram>,
317    source_semantics: ManagedSemanticSchemaFingerprint,
318    target_semantics: ManagedSemanticSchemaFingerprint,
319}
320
321impl BackfillStepContract {
322    fn derive(id: MigrationStepId, plan: &AttributeBackfillPlan) -> Result<Self, Diagnostic> {
323        Ok(Self {
324            id,
325            plan_fingerprint: plan.fingerprint()?,
326            recovery: RecoveryPolicy::OperatorRequired,
327            required_capabilities: plan.required_capabilities().clone(),
328            retry: RetryPolicy::Never,
329            reverse: plan.reverse(),
330            source_semantics: plan.managed_semantics().clone(),
331            target_semantics: plan.managed_semantics().clone(),
332        })
333    }
334
335    /// Return the step-local identity.
336    pub const fn id(&self) -> &MigrationStepId {
337        &self.id
338    }
339    /// Return the exact canonical backfill-plan fingerprint.
340    pub const fn plan_fingerprint(&self) -> &Fingerprint {
341        &self.plan_fingerprint
342    }
343    /// Return capabilities derived from the closed plan.
344    pub const fn required_capabilities(&self) -> &CapabilitySet {
345        &self.required_capabilities
346    }
347    /// Return the optional independently verifiable reverse program.
348    pub const fn reverse(&self) -> Option<crate::migration_backfill::BackfillReverseProgram> {
349        self.reverse
350    }
351    /// Return the exact historical intermediate-schema precondition.
352    pub const fn source_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
353        &self.source_semantics
354    }
355    /// Return the schema semantics preserved by the data step.
356    pub const fn target_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
357        &self.target_semantics
358    }
359}
360
361/// Context-free claims derived exactly from a trusted schema delta.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
363pub struct SchemaDeltaStepContract {
364    delta_fingerprint: SchemaDeltaFingerprint,
365    id: MigrationStepId,
366    recovery: RecoveryPolicy,
367    required_capabilities: CapabilitySet,
368    retry: RetryPolicy,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    reverse: Option<SchemaDelta>,
371    source_semantics: ManagedSemanticSchemaFingerprint,
372    target_semantics: ManagedSemanticSchemaFingerprint,
373}
374
375impl SchemaDeltaStepContract {
376    /// Derive all step claims and validate an optional exact operation-wise inverse.
377    pub fn new(
378        id: MigrationStepId,
379        delta: &SchemaDelta,
380        reverse: Option<SchemaDelta>,
381    ) -> Result<Self, Diagnostic> {
382        if let Some(candidate) = &reverse {
383            let inverse_operations = delta
384                .operations()
385                .iter()
386                .rev()
387                .flat_map(SchemaOperation::inverse)
388                .collect();
389            let expected = SchemaDelta::new(
390                delta.format(),
391                delta.target().clone(),
392                delta.source().clone(),
393                inverse_operations,
394            )?;
395            if candidate != &expected {
396                return Err(Diagnostic::stable(
397                    DiagnosticCategory::InvalidContract,
398                    "schema_delta_step_inverse_mismatch",
399                    "schema step reverse delta is not the exact operation-wise inverse",
400                ));
401            }
402        }
403        Ok(Self {
404            delta_fingerprint: SchemaDeltaFingerprint::compute(delta)?,
405            id,
406            recovery: RecoveryPolicy::OperatorRequired,
407            required_capabilities: delta.required_capabilities().clone(),
408            retry: RetryPolicy::Never,
409            reverse,
410            source_semantics: delta.source().managed_semantic_schema().clone(),
411            target_semantics: delta.target().managed_semantic_schema().clone(),
412        })
413    }
414
415    /// Return the step-local identity.
416    pub const fn id(&self) -> &MigrationStepId {
417        &self.id
418    }
419
420    /// Return the exact source managed-semantic precondition.
421    pub const fn source_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
422        &self.source_semantics
423    }
424
425    /// Return the exact target managed-semantic postcondition.
426    pub const fn target_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
427        &self.target_semantics
428    }
429
430    /// Return capabilities derived from the trusted delta.
431    pub const fn required_capabilities(&self) -> &CapabilitySet {
432        &self.required_capabilities
433    }
434
435    /// Return the exact schema-delta fingerprint.
436    pub const fn delta_fingerprint(&self) -> &SchemaDeltaFingerprint {
437        &self.delta_fingerprint
438    }
439
440    /// Return the fixed no-replay retry policy.
441    pub const fn retry(&self) -> RetryPolicy {
442        self.retry
443    }
444
445    /// Return the fixed operator-required recovery policy.
446    pub const fn recovery(&self) -> RecoveryPolicy {
447        self.recovery
448    }
449
450    /// Return the optional checked exact inverse.
451    pub const fn reverse(&self) -> Option<&SchemaDelta> {
452        self.reverse.as_ref()
453    }
454}
455
456/// One trusted schema-only migration step.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
458pub struct SchemaDeltaStep {
459    contract: SchemaDeltaStepContract,
460    delta: SchemaDelta,
461    kind: MigrationStepKind,
462}
463
464impl SchemaDeltaStep {
465    /// Construct a schema step and derive every context-free contract field.
466    pub fn new(
467        id: MigrationStepId,
468        delta: SchemaDelta,
469        reverse: Option<SchemaDelta>,
470    ) -> Result<Self, Diagnostic> {
471        let contract = SchemaDeltaStepContract::new(id, &delta, reverse)?;
472        Ok(Self {
473            contract,
474            delta,
475            kind: MigrationStepKind::SchemaDelta,
476        })
477    }
478
479    /// Return the fixed schema-delta step kind.
480    pub const fn kind(&self) -> MigrationStepKind {
481        self.kind
482    }
483
484    /// Return the derived step contract.
485    pub const fn contract(&self) -> &SchemaDeltaStepContract {
486        &self.contract
487    }
488
489    /// Return the trusted schema delta.
490    pub const fn delta(&self) -> &SchemaDelta {
491        &self.delta
492    }
493
494    /// Return exact canonical step bytes.
495    pub fn canonical_bytes(&self) -> Result<Vec<u8>, Diagnostic> {
496        to_canonical_json(self)
497    }
498}
499
500/// Context-free claims derived exactly from one canonical assertion plan.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
502pub struct AssertionStepContract {
503    id: MigrationStepId,
504    plan_fingerprint: MigrationAssertionPlanFingerprint,
505    recovery: RecoveryPolicy,
506    required_capabilities: CapabilitySet,
507    retry: RetryPolicy,
508    source_semantics: ManagedSemanticSchemaFingerprint,
509    target_semantics: ManagedSemanticSchemaFingerprint,
510}
511
512impl AssertionStepContract {
513    fn derive(
514        id: MigrationStepId,
515        plan: &MigrationAssertionPlan,
516        expected: AssertionExpectation,
517    ) -> Result<Self, Diagnostic> {
518        if expected != AssertionExpectation::NoRows
519            || plan.expectation() != AssertionExpectation::NoRows
520        {
521            return Err(Diagnostic::stable(
522                DiagnosticCategory::InvalidContract,
523                "migration_assertion_step_expectation_mismatch",
524                "persisted migration assertions support only the no-rows expectation",
525            ));
526        }
527        let mut required_capabilities = plan.required_capabilities().clone();
528        required_capabilities.insert(
529            CapabilityId::new(CONDITIONAL_RESOLUTION_CAPABILITY)
530                .expect("the fixed conditional-resolution capability is canonical"),
531        );
532        Ok(Self {
533            id,
534            plan_fingerprint: plan.fingerprint()?,
535            recovery: RecoveryPolicy::OperatorRequired,
536            required_capabilities,
537            retry: RetryPolicy::Never,
538            source_semantics: plan.managed_semantics().clone(),
539            target_semantics: plan.managed_semantics().clone(),
540        })
541    }
542
543    /// Return the step-local identity.
544    pub const fn id(&self) -> &MigrationStepId {
545        &self.id
546    }
547
548    /// Return the canonical assertion-plan fingerprint.
549    pub const fn plan_fingerprint(&self) -> &MigrationAssertionPlanFingerprint {
550        &self.plan_fingerprint
551    }
552
553    /// Return capabilities derived from the assertion syntax and resolution contract.
554    pub const fn required_capabilities(&self) -> &CapabilitySet {
555        &self.required_capabilities
556    }
557
558    /// Return the fixed no-replay retry policy.
559    pub const fn retry(&self) -> RetryPolicy {
560        self.retry
561    }
562
563    /// Return the fixed operator-required recovery policy.
564    pub const fn recovery(&self) -> RecoveryPolicy {
565        self.recovery
566    }
567
568    /// Return the exact source managed-semantic precondition.
569    pub const fn source_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
570        &self.source_semantics
571    }
572
573    /// Return the identical target managed-semantic postcondition.
574    pub const fn target_semantics(&self) -> &ManagedSemanticSchemaFingerprint {
575        &self.target_semantics
576    }
577
578    /// Assertions never have an independently executable reverse step.
579    pub const fn reverse(&self) -> Option<()> {
580        None
581    }
582}
583
584/// Exact ordered migration step algebra. Trusted steps deliberately do not deserialize.
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub enum MigrationStep {
587    /// One state-changing schema delta.
588    SchemaDelta(Box<SchemaDeltaStep>),
589    /// One state-preserving verifier-derived assertion.
590    Assertion {
591        /// Constructor-derived assertion contract.
592        contract: Box<AssertionStepContract>,
593        /// Canonical typed assertion plan.
594        plan: Box<MigrationAssertionPlan>,
595        /// Closed expected outcome.
596        expected: AssertionExpectation,
597    },
598    /// One state-preserving schema, state-changing data backfill.
599    Backfill {
600        /// Constructor-derived backfill contract.
601        contract: Box<BackfillStepContract>,
602        /// Closed canonical data plan.
603        plan: Box<AttributeBackfillPlan>,
604    },
605}
606
607impl MigrationStep {
608    /// Construct a trusted closed backfill step and derive all contract claims.
609    pub fn backfill(id: MigrationStepId, plan: AttributeBackfillPlan) -> Result<Self, Diagnostic> {
610        let contract = BackfillStepContract::derive(id, &plan)?;
611        Ok(Self::Backfill {
612            contract: Box::new(contract),
613            plan: Box::new(plan),
614        })
615    }
616    /// Construct a trusted assertion step and derive all contract claims.
617    pub fn assertion(
618        id: MigrationStepId,
619        plan: MigrationAssertionPlan,
620        expected: AssertionExpectation,
621    ) -> Result<Self, Diagnostic> {
622        let contract = AssertionStepContract::derive(id, &plan, expected)?;
623        Ok(Self::Assertion {
624            contract: Box::new(contract),
625            plan: Box::new(plan),
626            expected,
627        })
628    }
629
630    /// Return the closed step discriminator.
631    pub const fn kind(&self) -> MigrationStepKind {
632        match self {
633            Self::SchemaDelta(_) => MigrationStepKind::SchemaDelta,
634            Self::Assertion { .. } => MigrationStepKind::Assertion,
635            Self::Backfill { .. } => MigrationStepKind::Backfill,
636        }
637    }
638
639    /// Return the unique step-local identity.
640    pub const fn id(&self) -> &MigrationStepId {
641        match self {
642            Self::SchemaDelta(step) => step.contract().id(),
643            Self::Assertion { contract, .. } => contract.id(),
644            Self::Backfill { contract, .. } => contract.id(),
645        }
646    }
647
648    /// Return constructor-derived required capabilities.
649    pub const fn required_capabilities(&self) -> &CapabilitySet {
650        match self {
651            Self::SchemaDelta(step) => step.contract().required_capabilities(),
652            Self::Assertion { contract, .. } => contract.required_capabilities(),
653            Self::Backfill { contract, .. } => contract.required_capabilities(),
654        }
655    }
656
657    /// Return a schema-delta step when this is state-changing.
658    pub fn as_schema_delta(&self) -> Option<&SchemaDeltaStep> {
659        match self {
660            Self::SchemaDelta(step) => Some(step),
661            Self::Assertion { .. } | Self::Backfill { .. } => None,
662        }
663    }
664
665    /// Return assertion contract, plan, and expectation when state-preserving.
666    pub fn as_assertion(
667        &self,
668    ) -> Option<(
669        &AssertionStepContract,
670        &MigrationAssertionPlan,
671        AssertionExpectation,
672    )> {
673        match self {
674            Self::SchemaDelta(_) | Self::Backfill { .. } => None,
675            Self::Assertion {
676                contract,
677                plan,
678                expected,
679            } => Some((contract, plan, *expected)),
680        }
681    }
682
683    /// Return backfill contract and plan when this is a data step.
684    pub fn as_backfill(&self) -> Option<(&BackfillStepContract, &AttributeBackfillPlan)> {
685        match self {
686            Self::Backfill { contract, plan } => Some((contract, plan)),
687            Self::SchemaDelta(_) | Self::Assertion { .. } => None,
688        }
689    }
690
691    /// Rebuild all derived claims and reject an assembled mismatched step.
692    pub fn validate(&self) -> Result<(), Diagnostic> {
693        let rebuilt = match self {
694            Self::SchemaDelta(step) => Self::SchemaDelta(Box::new(SchemaDeltaStep::new(
695                step.contract().id().clone(),
696                step.delta().clone(),
697                step.contract().reverse().cloned(),
698            )?)),
699            Self::Assertion {
700                contract,
701                plan,
702                expected,
703            } => Self::assertion(contract.id().clone(), plan.as_ref().clone(), *expected)?,
704            Self::Backfill { contract, plan } => {
705                Self::backfill(contract.id().clone(), plan.as_ref().clone())?
706            }
707        };
708        if &rebuilt != self {
709            return Err(Diagnostic::stable(
710                DiagnosticCategory::Integrity,
711                "migration_step_contract_mismatch",
712                "migration step claims differ from constructor-derived claims",
713            ));
714        }
715        Ok(())
716    }
717
718    /// Return exact canonical heterogeneous step bytes.
719    pub fn canonical_bytes(&self) -> Result<Vec<u8>, Diagnostic> {
720        to_canonical_json(self)
721    }
722}
723
724impl From<SchemaDeltaStep> for MigrationStep {
725    fn from(step: SchemaDeltaStep) -> Self {
726        Self::SchemaDelta(Box::new(step))
727    }
728}
729
730#[derive(Serialize)]
731struct AssertionStepView<'a> {
732    contract: &'a AssertionStepContract,
733    expected: AssertionExpectation,
734    kind: MigrationStepKind,
735    plan: &'a MigrationAssertionPlan,
736}
737
738#[derive(Serialize)]
739struct BackfillStepView<'a> {
740    contract: &'a BackfillStepContract,
741    kind: MigrationStepKind,
742    plan: &'a AttributeBackfillPlan,
743}
744
745impl Serialize for MigrationStep {
746    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
747    where
748        S: Serializer,
749    {
750        match self {
751            Self::SchemaDelta(step) => step.serialize(serializer),
752            Self::Assertion {
753                contract,
754                plan,
755                expected,
756            } => AssertionStepView {
757                contract,
758                expected: *expected,
759                kind: MigrationStepKind::Assertion,
760                plan,
761            }
762            .serialize(serializer),
763            Self::Backfill { contract, plan } => BackfillStepView {
764                contract,
765                kind: MigrationStepKind::Backfill,
766                plan,
767            }
768            .serialize(serializer),
769        }
770    }
771}
772
773#[derive(Serialize)]
774struct MigrationPlanView<'a> {
775    steps: &'a [MigrationStep],
776}
777
778/// Fingerprint of the exact ordered schema-only step algebra.
779#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
780#[serde(transparent)]
781pub struct MigrationPlanFingerprint(Fingerprint);
782
783impl MigrationPlanFingerprint {
784    /// Return canonical bytes for the exact ordered schema-only plan.
785    pub fn canonical_plan_bytes(steps: &[MigrationStep]) -> Result<Vec<u8>, Diagnostic> {
786        to_canonical_json(&MigrationPlanView { steps })
787    }
788
789    /// Compute an ordered plan fingerprint; changing step order changes the digest.
790    pub fn compute(steps: &[MigrationStep]) -> Result<Self, Diagnostic> {
791        Ok(Self(Fingerprint::compute(
792            FingerprintDomain::new(MIGRATION_PLAN_FINGERPRINT_DOMAIN)?,
793            CanonicalizationVersion::new(MIGRATION_PLAN_FINGERPRINT_CANONICALIZATION)?,
794            None,
795            &Self::canonical_plan_bytes(steps)?,
796        )))
797    }
798
799    /// Return the generic domain-separated fingerprint.
800    pub const fn as_fingerprint(&self) -> &Fingerprint {
801        &self.0
802    }
803}