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