Skip to main content

type_bridge_schema_migration/
legacy.rs

1//! Legacy (v1) migration references recorded by the frontier bridge.
2//!
3//! Cutover from the legacy Python/JSON migration format creates one
4//! immutable, zero-operation legacy-frontier-bridge canonical manifest per
5//! managed scope. Only that node names legacy parents; it records each
6//! legacy frontier compound identity with its tagged checksum, and its
7//! verified source and target are the identical reconstructed legacy head.
8//! Import verifies the ledger and live state against that head — it never
9//! replays already-applied legacy steps.
10
11use sha2::{Digest as _, Sha256};
12use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
13use type_bridge_contract::limits::{
14    MAX_CANONICAL_BYTES, MAX_CANONICAL_COLLECTION_LEN, MAX_CANONICAL_STRING_BYTES,
15};
16use type_bridge_contract::migration::MigrationId;
17use type_bridge_contract::schema::DeclaredSchema;
18use type_bridge_schema::ManagedDeltaContext;
19
20use crate::manifest::{
21    SchemaMigrationDraft, VerifiedSchemaMigrationManifest, build_verified_manifest,
22};
23
24/// Tag identifying the legacy checksum algorithm: SHA-256 over the authored
25/// Python migration source, hex-encoded and truncated to 16 characters.
26pub const LEGACY_CHECKSUM_ALGORITHM: &str = "python-source-sha256/16";
27/// Canonicalization/domain vocabulary for the complete released applied set.
28pub const LEGACY_APPLIED_SET_CANONICALIZATION: &str = "typebridge.legacy-applied-set/v1";
29/// Digest algorithm vocabulary for the complete released applied set.
30pub const LEGACY_APPLIED_SET_ALGORITHM: &str = "sha256";
31
32const LEGACY_CHECKSUM_LEN: usize = 16;
33
34fn validate_legacy_component(value: String, kind: &'static str) -> Result<String, Diagnostic> {
35    if !value.is_empty() && value.len() <= MAX_CANONICAL_STRING_BYTES {
36        Ok(value)
37    } else {
38        Err(failure(
39            "migration_legacy_identity_component_invalid",
40            "legacy migration identity components must be nonempty bounded UTF-8",
41        )
42        .with_detail("component_kind", kind))
43    }
44}
45
46macro_rules! legacy_component {
47    ($name:ident, $doc:literal, $kind:literal) => {
48        #[doc = $doc]
49        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50        pub struct $name(String);
51
52        impl $name {
53            /// Construct one lossless released identity component.
54            pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
55                Ok(Self(validate_legacy_component(value.into(), $kind)?))
56            }
57
58            /// Return the exact released spelling.
59            pub fn as_str(&self) -> &str {
60                &self.0
61            }
62        }
63    };
64}
65
66legacy_component!(
67    LegacyMigrationAppLabel,
68    "A lossless bounded UTF-8 application label from released migration history.",
69    "app_label"
70);
71legacy_component!(
72    LegacyMigrationName,
73    "A lossless bounded UTF-8 migration name from released migration history.",
74    "name"
75);
76
77/// Lossless compound identity for a released migration frontier member.
78///
79/// This is deliberately distinct from canonical V2 [`MigrationId`]: released
80/// V1 directory labels and sidecar names admitted UTF-8 spellings outside the
81/// portable lowercase grammar required for newly authored V2 manifests.
82#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
83pub struct LegacyMigrationId {
84    app_label: LegacyMigrationAppLabel,
85    name: LegacyMigrationName,
86}
87
88impl LegacyMigrationId {
89    /// Construct a lossless bounded released identity.
90    pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Result<Self, Diagnostic> {
91        Ok(Self {
92            app_label: LegacyMigrationAppLabel::new(app_label)?,
93            name: LegacyMigrationName::new(name)?,
94        })
95    }
96
97    /// Return the exact released application label.
98    pub const fn app_label(&self) -> &LegacyMigrationAppLabel {
99        &self.app_label
100    }
101
102    /// Return the exact released migration name.
103    pub const fn name(&self) -> &LegacyMigrationName {
104        &self.name
105    }
106}
107
108impl From<MigrationId> for LegacyMigrationId {
109    fn from(id: MigrationId) -> Self {
110        Self {
111            app_label: LegacyMigrationAppLabel(id.app_label().as_str().to_owned()),
112            name: LegacyMigrationName(id.name().as_str().to_owned()),
113        }
114    }
115}
116
117/// A tagged legacy migration checksum.
118///
119/// The type is deliberately distinct from every canonical digest so the
120/// legacy truncated Python checksum can never be confused with a full
121/// canonical-manifest digest.
122#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
123pub struct LegacyMigrationChecksum(String);
124
125impl LegacyMigrationChecksum {
126    /// Validate a canonical 16-character lowercase-hex legacy checksum.
127    pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
128        let value = value.into();
129        if value.len() != LEGACY_CHECKSUM_LEN
130            || !value
131                .bytes()
132                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
133        {
134            return Err(failure(
135                "migration_legacy_checksum_invalid",
136                "legacy checksum must be exactly 16 lowercase hexadecimal characters",
137            ));
138        }
139        Ok(Self(value))
140    }
141
142    /// Return the canonical checksum text.
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146
147    /// Return the tagged algorithm this checksum was computed under.
148    pub const fn algorithm(&self) -> &'static str {
149        LEGACY_CHECKSUM_ALGORITHM
150    }
151}
152
153/// One legacy frontier migration named by the canonical bridge.
154#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
155pub struct LegacyMigrationReference {
156    id: LegacyMigrationId,
157    checksum: LegacyMigrationChecksum,
158}
159
160impl LegacyMigrationReference {
161    /// Bind one legacy compound identity to its tagged checksum.
162    pub fn new(id: impl Into<LegacyMigrationId>, checksum: LegacyMigrationChecksum) -> Self {
163        Self {
164            id: id.into(),
165            checksum,
166        }
167    }
168
169    /// Return the legacy compound migration identity.
170    pub const fn id(&self) -> &LegacyMigrationId {
171        &self.id
172    }
173
174    /// Return the tagged legacy checksum.
175    pub const fn checksum(&self) -> &LegacyMigrationChecksum {
176        &self.checksum
177    }
178}
179
180/// Digest binding every semantic row in the released applied ledger.
181///
182/// The preimage excludes `applied_at`: released insertion timestamps do not
183/// change which migrations/checksums are applied. Exact UTF-8 identity and
184/// checksum triples are sorted, count-prefixed, and length-delimited so
185/// component boundaries cannot collide.
186#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
187pub struct LegacyAppliedSetDigest(String);
188
189impl LegacyAppliedSetDigest {
190    /// Compute the domain-separated digest of one complete applied set.
191    pub fn compute(
192        references: impl IntoIterator<Item = LegacyMigrationReference>,
193    ) -> Result<Self, Diagnostic> {
194        let mut collected = Vec::new();
195        let mut preimage_bytes = LEGACY_APPLIED_SET_CANONICALIZATION
196            .len()
197            .checked_add(1 + std::mem::size_of::<u64>())
198            .ok_or_else(applied_set_too_large)?;
199        for reference in references {
200            if collected.len() == MAX_CANONICAL_COLLECTION_LEN {
201                return Err(failure(
202                    "migration_legacy_applied_set_too_many_rows",
203                    "legacy applied-set digest exceeds the canonical collection ceiling",
204                ));
205            }
206            for field in [
207                reference.id().app_label().as_str().as_bytes(),
208                reference.id().name().as_str().as_bytes(),
209                reference.checksum().as_str().as_bytes(),
210            ] {
211                preimage_bytes = preimage_bytes
212                    .checked_add(std::mem::size_of::<u64>())
213                    .and_then(|bytes| bytes.checked_add(field.len()))
214                    .ok_or_else(applied_set_too_large)?;
215            }
216            if preimage_bytes > MAX_CANONICAL_BYTES {
217                return Err(applied_set_too_large());
218            }
219            collected.push(reference);
220        }
221        let mut references = collected;
222        references.sort();
223        if references.is_empty() {
224            return Err(failure(
225                "migration_legacy_applied_set_empty",
226                "legacy applied-set digest requires at least one migration",
227            ));
228        }
229        if references
230            .windows(2)
231            .any(|pair| pair[0].id() == pair[1].id())
232        {
233            return Err(failure(
234                "migration_legacy_applied_set_duplicate",
235                "legacy applied-set digest contains a duplicate migration identity",
236            ));
237        }
238        let mut hasher = Sha256::new();
239        hasher.update(LEGACY_APPLIED_SET_CANONICALIZATION.as_bytes());
240        hasher.update([0]);
241        let row_count = u64::try_from(references.len()).map_err(|_| applied_set_too_large())?;
242        hasher.update(row_count.to_be_bytes());
243        for reference in &references {
244            hash_field(&mut hasher, reference.id().app_label().as_str().as_bytes());
245            hash_field(&mut hasher, reference.id().name().as_str().as_bytes());
246            hash_field(&mut hasher, reference.checksum().as_str().as_bytes());
247        }
248        Ok(Self(hex_digest(hasher.finalize())))
249    }
250
251    /// Validate a persisted lowercase SHA-256 digest.
252    pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
253        let value = value.into();
254        if value.len() != 64
255            || !value
256                .bytes()
257                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
258        {
259            return Err(failure(
260                "migration_legacy_applied_set_digest_invalid",
261                "legacy applied-set digest must be 64 lowercase hexadecimal characters",
262            ));
263        }
264        Ok(Self(value))
265    }
266
267    /// Return the fixed digest algorithm vocabulary.
268    pub const fn algorithm(&self) -> &'static str {
269        LEGACY_APPLIED_SET_ALGORITHM
270    }
271
272    /// Return the fixed preimage canonicalization/domain vocabulary.
273    pub const fn canonicalization(&self) -> &'static str {
274        LEGACY_APPLIED_SET_CANONICALIZATION
275    }
276
277    /// Return the lowercase digest text.
278    pub fn as_str(&self) -> &str {
279        &self.0
280    }
281}
282
283/// Build the verified zero-operation bridge for one legacy frontier.
284///
285/// `reconstructed_head` is the managed schema the completed legacy history
286/// reaches; the bridge verifies against it as its genesis source and records
287/// an identical target, so applying the bridge executes nothing and the
288/// coordinator's live-state gate proves the database sits at that exact head.
289pub fn build_legacy_frontier_bridge(
290    id: MigrationId,
291    legacy_frontier: Vec<LegacyMigrationReference>,
292    legacy_applied_set: LegacyAppliedSetDigest,
293    reconstructed_head: &DeclaredSchema,
294    context: &ManagedDeltaContext,
295) -> Result<VerifiedSchemaMigrationManifest, Diagnostic> {
296    build_verified_manifest(
297        SchemaMigrationDraft::legacy_bridge(id, legacy_frontier, legacy_applied_set)?,
298        (reconstructed_head, context),
299    )
300}
301
302fn hash_field(hasher: &mut Sha256, value: &[u8]) {
303    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
304    hasher.update(value);
305}
306
307fn applied_set_too_large() -> Diagnostic {
308    failure(
309        "migration_legacy_applied_set_too_large",
310        "legacy applied-set digest exceeds the canonical byte ceiling",
311    )
312}
313
314fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
315    bytes
316        .as_ref()
317        .iter()
318        .map(|byte| format!("{byte:02x}"))
319        .collect()
320}
321
322fn failure(code: &'static str, message: &'static str) -> Diagnostic {
323    Diagnostic::new(
324        DiagnosticCategory::InvalidContract,
325        DiagnosticCode::new(code).expect("static legacy diagnostic code is canonical"),
326        message,
327    )
328}