Skip to main content

type_bridge_migration/
adoption.rs

1//! Read-only legacy-history evidence used by the V2 adoption checkpoint.
2//!
3//! Adoption never executes a legacy operation. A separate archival record is
4//! therefore required even when released discovery selected an executable JSON
5//! sidecar, and also covers migrations whose Python operation graph cannot be
6//! lowered (notably `RunPython`). Sidecar authority binds and reparses both
7//! exact files; source authority binds frozen Python identity and dependencies.
8//! Every record carries an explicit immutable schema authority and cannot enter
9//! the native execution loader. Proven schema-neutral `RunPython`, empty, and
10//! sidecar `CopyAttribute` histories may inherit the one snapshot authority
11//! shared by all parents.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::{OsStr, OsString};
15#[cfg(test)]
16use std::fs;
17use std::io;
18use std::io::{Read as _, Write as _};
19use std::path::{Component, Path, PathBuf};
20use std::time::SystemTime;
21
22use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
23use cap_std::ambient_authority;
24use cap_std::fs::{Dir, Metadata};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest as _, Sha256};
27
28#[cfg(test)]
29use crate::checksum::migration_file_checksum;
30use crate::error::{MigrationError, Result};
31use crate::graph::validate_graph;
32use crate::spec::{MigrationDependencySpec, MigrationGraph, MigrationSpec, OperationSpec};
33
34/// Frozen discriminator for non-executable legacy adoption metadata.
35pub const LEGACY_ADOPTION_METADATA_V2: &str = "typebridge.migration-adoption-metadata/v2";
36/// Frozen discriminator for released sidecar-precedence adoption metadata.
37pub const LEGACY_SIDECAR_ADOPTION_METADATA_V1: &str = "typebridge.migration-adoption-sidecar/v1";
38/// Frozen discriminator for a migration-shaped Python source ignored by V1.
39pub const LEGACY_IGNORED_SOURCE_METADATA_V1: &str =
40    "typebridge.migration-adoption-ignored-source/v1";
41
42/// Maximum number of entries retained while inspecting one legacy directory.
43pub const MAX_LEGACY_DIRECTORY_ENTRIES: usize = 65_536;
44/// Maximum bytes read from any one legacy artifact.
45pub const MAX_LEGACY_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
46/// Maximum aggregate bytes retained while inspecting one legacy history.
47pub const MAX_LEGACY_HISTORY_BYTES: usize = 256 * 1024 * 1024;
48
49const ADOPTION_SUFFIX: &str = ".adoption.json";
50const SNAPSHOT_MANIFEST: &str = "snapshot.json";
51const SNAPSHOT_SCHEMA: &str = "schema.tql";
52const CONVERSION_JOURNAL: &str = ".typebridge-adoption-conversion.json";
53
54#[cfg(test)]
55thread_local! {
56    static TEST_CAPTURED_DIRECTORY_ENTRIES: std::cell::Cell<usize> = const {
57        std::cell::Cell::new(0)
58    };
59    static TEST_BEFORE_JOURNAL_QUARANTINE: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
60        std::cell::RefCell::new(None);
61    static TEST_AFTER_SNAPSHOT_SCAN: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
62        std::cell::RefCell::new(None);
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66enum LegacyEntryKind {
67    File,
68    Directory,
69    Symlink,
70    Other,
71}
72
73/// Opaque, mutation-sensitive identity for one retained adoption entry.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct LegacyMetadataRevision {
76    kind: LegacyEntryKind,
77    len: u64,
78    modified: Option<SystemTime>,
79    created: Option<SystemTime>,
80    #[cfg(unix)]
81    device: u64,
82    #[cfg(unix)]
83    inode: u64,
84    #[cfg(unix)]
85    modified_seconds: i64,
86    #[cfg(unix)]
87    modified_nanoseconds: i64,
88    #[cfg(unix)]
89    changed_seconds: i64,
90    #[cfg(unix)]
91    changed_nanoseconds: i64,
92    #[cfg(windows)]
93    volume_serial_number: u32,
94    #[cfg(windows)]
95    file_index: u64,
96    #[cfg(windows)]
97    number_of_links: u32,
98    #[cfg(windows)]
99    file_attributes: u32,
100    #[cfg(windows)]
101    creation_time: u64,
102    #[cfg(windows)]
103    last_write_time: u64,
104}
105
106impl LegacyMetadataRevision {
107    fn capture(metadata: &Metadata) -> Result<Self> {
108        let kind = if metadata.is_file() {
109            LegacyEntryKind::File
110        } else if metadata.is_dir() {
111            LegacyEntryKind::Directory
112        } else if metadata.is_symlink() {
113            LegacyEntryKind::Symlink
114        } else {
115            LegacyEntryKind::Other
116        };
117        #[cfg(unix)]
118        {
119            use cap_std::fs::MetadataExt as _;
120            Ok(Self {
121                kind,
122                len: metadata.len(),
123                modified: metadata.modified().ok().map(|value| value.into_std()),
124                created: metadata.created().ok().map(|value| value.into_std()),
125                device: metadata.dev(),
126                inode: metadata.ino(),
127                modified_seconds: metadata.mtime(),
128                modified_nanoseconds: metadata.mtime_nsec(),
129                changed_seconds: metadata.ctime(),
130                changed_nanoseconds: metadata.ctime_nsec(),
131            })
132        }
133        #[cfg(windows)]
134        {
135            // The Option-returning by-handle accessors are public but
136            // doc(hidden) in cap-primitives; cap_fs_ext::MetadataExt only
137            // re-exposes them as panicking dev()/ino()/nlink(), and
138            // cap_std::fs::MetadataExt carries them only under the opt-in
139            // windows_by_handle cfg, so unqualified calls are ambiguous or
140            // unresolved depending on that cfg. Call through the trait.
141            use cap_primitives::fs::_WindowsByHandle;
142            use cap_std::fs::MetadataExt as _;
143            let volume_serial_number = _WindowsByHandle::volume_serial_number(metadata)
144                .ok_or_else(|| {
145                    loader_error("legacy authority metadata has no stable Windows volume identity")
146                })?;
147            let file_index = _WindowsByHandle::file_index(metadata).ok_or_else(|| {
148                loader_error("legacy authority metadata has no stable Windows file identity")
149            })?;
150            let number_of_links = _WindowsByHandle::number_of_links(metadata).ok_or_else(|| {
151                loader_error("legacy authority metadata has no stable Windows link identity")
152            })?;
153            Ok(Self {
154                kind,
155                len: metadata.len(),
156                modified: metadata.modified().ok().map(|value| value.into_std()),
157                created: metadata.created().ok().map(|value| value.into_std()),
158                volume_serial_number,
159                file_index,
160                number_of_links,
161                file_attributes: _WindowsByHandle::file_attributes(metadata),
162                creation_time: metadata.creation_time(),
163                last_write_time: metadata.last_write_time(),
164            })
165        }
166        #[cfg(not(any(unix, windows)))]
167        {
168            let _ = (kind, metadata);
169            Err(loader_error(
170                "legacy directory authority is unsupported without stable filesystem identity",
171            ))
172        }
173    }
174
175    fn same_retained_directory(&self, other: &Self) -> bool {
176        if self.kind != LegacyEntryKind::Directory || other.kind != LegacyEntryKind::Directory {
177            return false;
178        }
179        #[cfg(unix)]
180        {
181            // Renaming the caller-supplied root changes only its ctime on
182            // Unix. The retained directory capability remains the same
183            // authority; child membership changes still alter size/mtime and
184            // every relevant child is independently revision-checked.
185            self.len == other.len
186                && self.modified == other.modified
187                && self.created == other.created
188                && self.device == other.device
189                && self.inode == other.inode
190                && self.modified_seconds == other.modified_seconds
191                && self.modified_nanoseconds == other.modified_nanoseconds
192        }
193        #[cfg(not(unix))]
194        {
195            self == other
196        }
197    }
198
199    fn same_file_after_rename(&self, other: &Self) -> bool {
200        if self.kind != LegacyEntryKind::File || other.kind != LegacyEntryKind::File {
201            return false;
202        }
203        #[cfg(unix)]
204        {
205            self.len == other.len
206                && self.modified == other.modified
207                && self.created == other.created
208                && self.device == other.device
209                && self.inode == other.inode
210                && self.modified_seconds == other.modified_seconds
211                && self.modified_nanoseconds == other.modified_nanoseconds
212        }
213        #[cfg(not(unix))]
214        {
215            self == other
216        }
217    }
218}
219
220/// One no-follow directory entry captured through retained adoption authority.
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct LegacyDirectoryEntry {
223    name: OsString,
224    revision: LegacyMetadataRevision,
225}
226
227impl LegacyDirectoryEntry {
228    /// Return the direct-child name bound by this observation.
229    pub fn name(&self) -> &OsStr {
230        &self.name
231    }
232
233    /// Return the opaque entry revision token.
234    pub const fn revision(&self) -> &LegacyMetadataRevision {
235        &self.revision
236    }
237
238    /// Return whether this observation names a regular file.
239    pub const fn is_file(&self) -> bool {
240        matches!(self.revision.kind, LegacyEntryKind::File)
241    }
242
243    /// Return whether this observation names a real directory.
244    pub const fn is_directory(&self) -> bool {
245        matches!(self.revision.kind, LegacyEntryKind::Directory)
246    }
247
248    /// Return whether this observation names a symbolic link.
249    pub const fn is_symlink(&self) -> bool {
250        matches!(self.revision.kind, LegacyEntryKind::Symlink)
251    }
252}
253
254#[derive(Clone, Debug)]
255struct LegacyDirectoryCapture {
256    revision: LegacyMetadataRevision,
257    entries: Vec<LegacyDirectoryEntry>,
258}
259
260/// Retained, cross-platform directory authority for legacy adoption only.
261pub struct LegacyDirectoryAuthority {
262    directory: Dir,
263    display_path: PathBuf,
264}
265
266impl std::fmt::Debug for LegacyDirectoryAuthority {
267    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        formatter
269            .debug_struct("LegacyDirectoryAuthority")
270            .field("display_path", &self.display_path)
271            .finish_non_exhaustive()
272    }
273}
274
275impl LegacyDirectoryAuthority {
276    /// Follow the caller-supplied root once and retain the resulting directory.
277    pub fn open_root(path: &Path) -> Result<Self> {
278        let display_path = lexical_absolute(path).map_err(|_| {
279            loader_error("legacy directory cannot be retained as filesystem authority")
280        })?;
281        let directory =
282            Dir::open_ambient_dir(&display_path, ambient_authority()).map_err(|_| {
283                loader_error("legacy directory cannot be retained as filesystem authority")
284            })?;
285        Ok(Self {
286            directory,
287            display_path,
288        })
289    }
290
291    fn open_child(&self, entry: &LegacyDirectoryEntry) -> Result<Self> {
292        if entry.revision.kind != LegacyEntryKind::Directory {
293            return Err(loader_error(
294                "legacy directory entry is not a retained real directory",
295            ));
296        }
297        let directory = self
298            .directory
299            .open_dir_nofollow(&entry.name)
300            .map_err(|_| loader_error("legacy directory descendant cannot be retained"))?;
301        let actual = LegacyMetadataRevision::capture(
302            &directory
303                .metadata(".")
304                .map_err(|_| loader_error("legacy directory descendant cannot be inspected"))?,
305        )?;
306        if !actual.same_retained_directory(&entry.revision) {
307            return Err(loader_error(
308                "legacy directory descendant changed after enumeration",
309            ));
310        }
311        Ok(Self {
312            directory,
313            display_path: self.display_path.join(&entry.name),
314        })
315    }
316
317    fn capture(&self) -> Result<LegacyDirectoryCapture> {
318        self.capture_with_limit(MAX_LEGACY_DIRECTORY_ENTRIES)
319    }
320
321    fn capture_with_limit(&self, maximum_entries: usize) -> Result<LegacyDirectoryCapture> {
322        let before = self.revision()?;
323        let mut entries = Vec::new();
324        for entry in self
325            .directory
326            .read_dir(".")
327            .map_err(|_| loader_error("legacy directory authority cannot be enumerated"))?
328        {
329            if entries.len() == maximum_entries {
330                return Err(loader_error("legacy directory exceeds the entry ceiling"));
331            }
332            let entry = entry
333                .map_err(|_| loader_error("legacy directory authority cannot be enumerated"))?;
334            let name = entry.file_name();
335            let metadata = self
336                .directory
337                .symlink_metadata(&name)
338                .map_err(|_| loader_error("legacy directory entry cannot be inspected"))?;
339            entries.push(LegacyDirectoryEntry {
340                name,
341                revision: LegacyMetadataRevision::capture(&metadata)?,
342            });
343            #[cfg(test)]
344            TEST_CAPTURED_DIRECTORY_ENTRIES.with(|count| count.set(count.get() + 1));
345        }
346        entries.sort_by(|left, right| left.name.cmp(&right.name));
347        let after = self.revision()?;
348        if !before.same_retained_directory(&after) {
349            return Err(loader_error(
350                "legacy directory changed during bounded enumeration",
351            ));
352        }
353        Ok(LegacyDirectoryCapture {
354            revision: after,
355            entries,
356        })
357    }
358
359    fn require_revision(&self, expected: &LegacyMetadataRevision) -> Result<()> {
360        if !self.revision()?.same_retained_directory(expected) {
361            return Err(loader_error(
362                "legacy directory changed during bounded adoption",
363            ));
364        }
365        Ok(())
366    }
367
368    fn require_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
369        self.require_revision(&capture.revision)?;
370        for expected in &capture.entries {
371            let metadata = self
372                .directory
373                .symlink_metadata(&expected.name)
374                .map_err(|_| loader_error("legacy retained entry is absent during revalidation"))?;
375            if LegacyMetadataRevision::capture(&metadata)? != expected.revision {
376                return Err(loader_error(
377                    "legacy retained entry changed after bounded capture",
378                ));
379            }
380        }
381        self.require_revision(&capture.revision)
382    }
383
384    fn require_filtered_capture(
385        &self,
386        capture: &LegacyDirectoryCapture,
387        include: impl Fn(&OsStr) -> bool,
388        message: &'static str,
389    ) -> Result<()> {
390        self.require_capture(capture)?;
391        let mut observed = self.capture()?;
392        observed.entries.retain(|entry| include(&entry.name));
393        if observed.entries != capture.entries {
394            return Err(loader_error(message));
395        }
396        self.require_capture(capture)
397    }
398
399    fn require_recognized_root_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
400        self.require_filtered_capture(
401            capture,
402            is_recognized_legacy_root_name,
403            "recognized legacy migration membership changed after bounded capture",
404        )
405    }
406
407    fn require_snapshot_version_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
408        self.require_filtered_capture(
409            capture,
410            |name| name.to_str().is_some_and(is_snapshot_version),
411            "legacy snapshot version membership changed after bounded capture",
412        )
413    }
414
415    fn revision(&self) -> Result<LegacyMetadataRevision> {
416        self.directory
417            .metadata(".")
418            .map_err(|_| loader_error("legacy directory authority cannot be inspected"))
419            .and_then(|metadata| LegacyMetadataRevision::capture(&metadata))
420    }
421
422    /// Return an opaque revision of the retained root.
423    pub fn directory_revision(&self) -> Result<LegacyMetadataRevision> {
424        self.revision()
425    }
426
427    /// Reject when the retained root no longer has the supplied revision.
428    pub fn require_directory_revision(&self, expected: &LegacyMetadataRevision) -> Result<()> {
429        self.require_revision(expected)
430    }
431
432    /// Enumerate one retained descendant, stopping before retaining entry N+1.
433    pub fn entries_relative(
434        &self,
435        relative: &Path,
436        maximum_entries: usize,
437        expected_directory: Option<&LegacyDirectoryEntry>,
438    ) -> Result<Vec<LegacyDirectoryEntry>> {
439        let directory = self.open_relative_directory(relative)?;
440        if let Some(expected) = expected_directory
441            && (expected.revision.kind != LegacyEntryKind::Directory
442                || !directory
443                    .revision()?
444                    .same_retained_directory(&expected.revision))
445        {
446            return Err(loader_error(
447                "legacy directory descendant changed after enumeration",
448            ));
449        }
450        let capture =
451            directory.capture_with_limit(maximum_entries.min(MAX_LEGACY_DIRECTORY_ENTRIES))?;
452        if let Some(expected) = expected_directory
453            && !capture.revision.same_retained_directory(&expected.revision)
454        {
455            return Err(loader_error(
456                "legacy directory descendant changed during enumeration",
457            ));
458        }
459        Ok(capture.entries)
460    }
461
462    /// Inspect one retained descendant without following its final component.
463    pub fn inspect_relative(
464        &self,
465        relative: &Path,
466        expected_parent: Option<&LegacyDirectoryEntry>,
467    ) -> Result<Option<LegacyDirectoryEntry>> {
468        let (parent, name) = self.open_relative_parent(relative)?;
469        if let Some(expected) = expected_parent
470            && (expected.revision.kind != LegacyEntryKind::Directory
471                || !parent
472                    .revision()?
473                    .same_retained_directory(&expected.revision))
474        {
475            return Err(loader_error(
476                "legacy directory descendant changed after enumeration",
477            ));
478        }
479        match parent.directory.symlink_metadata(&name) {
480            Ok(metadata) => Ok(Some(LegacyDirectoryEntry {
481                name,
482                revision: LegacyMetadataRevision::capture(&metadata)?,
483            })),
484            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
485            Err(_) => Err(loader_error("legacy directory entry cannot be inspected")),
486        }
487    }
488
489    /// Read one stable regular descendant through retained no-follow authority.
490    pub fn read_relative_bounded(
491        &self,
492        relative: &Path,
493        limit: usize,
494        expected: Option<&LegacyDirectoryEntry>,
495    ) -> Result<Vec<u8>> {
496        let (parent, name) = self.open_relative_parent(relative)?;
497        let observed = match expected {
498            Some(entry) if entry.name == name => entry.clone(),
499            Some(_) => {
500                return Err(loader_error(
501                    "legacy artifact identity does not match its retained name",
502                ));
503            }
504            None => parent
505                .inspect_relative(Path::new(&name), None)?
506                .ok_or_else(|| loader_error("legacy artifact is absent"))?,
507        };
508        let mut aggregate = 0;
509        read_bounded(
510            &parent,
511            &observed,
512            limit.min(MAX_LEGACY_ARTIFACT_BYTES),
513            &mut aggregate,
514        )
515    }
516
517    /// Publish one direct regular child atomically without replacing authority.
518    pub fn write_atomic_no_replace(&self, name: &str, contents: &[u8]) -> Result<()> {
519        validate_direct_component(Path::new(name))?;
520        if contents.len() > MAX_LEGACY_ARTIFACT_BYTES {
521            return Err(loader_error(
522                "legacy artifact publication exceeds the byte ceiling",
523            ));
524        }
525        let mut temporary = None;
526        for attempt in 0..128_u64 {
527            let candidate = adoption_temporary_name("pub", name, contents, attempt);
528            let mut options = cap_std::fs::OpenOptions::new();
529            options
530                .write(true)
531                .create_new(true)
532                .follow(FollowSymlinks::No);
533            match self.directory.open_with(&candidate, &options) {
534                Ok(mut file) => {
535                    if file
536                        .write_all(contents)
537                        .and_then(|()| file.sync_all())
538                        .is_err()
539                    {
540                        // Close first so Windows can unlink the failed
541                        // temporary as well. Final publication has not begun.
542                        drop(file);
543                        let _ = self.directory.remove_file(&candidate);
544                        return Err(loader_error("legacy artifact temporary cannot be flushed"));
545                    }
546                    temporary = Some(candidate);
547                    break;
548                }
549                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
550                Err(_) => {
551                    return Err(loader_error("legacy artifact temporary cannot be created"));
552                }
553            }
554        }
555        let temporary = temporary
556            .ok_or_else(|| loader_error("legacy artifact temporary name ceiling exhausted"))?;
557        let publication = self.directory.hard_link(&temporary, &self.directory, name);
558        let _ = self.directory.remove_file(&temporary);
559        publication.map_err(|_| loader_error("legacy artifact no-replace publication failed"))?;
560        self.sync_directory()
561    }
562
563    /// Validate a prospective final artifact name without mutating the directory.
564    pub fn validate_publication_name(&self, name: &str) -> Result<()> {
565        let _ = self;
566        validate_direct_component(Path::new(name))
567    }
568
569    /// Remove one proof-bearing converter temporary after exact current-plan
570    /// validation by the caller.
571    ///
572    /// The source is first moved without replacement into the equally
573    /// proof-bearing `gc` namespace. Its retained inode and exact body are
574    /// rechecked there before unlink, so a raced replacement is restored and
575    /// never deleted. A crash after the rename leaves another recoverable
576    /// current-plan temporary rather than unowned hidden state.
577    pub fn remove_owned_temporary_if_matches(
578        &self,
579        name: &str,
580        target: &str,
581        expected: &LegacyDirectoryEntry,
582        expected_bytes: &[u8],
583    ) -> Result<bool> {
584        validate_direct_component(Path::new(name))?;
585        validate_direct_component(Path::new(target))?;
586        if expected.name != OsStr::new(name)
587            || expected_bytes.len() > MAX_LEGACY_ARTIFACT_BYTES
588            || !adoption_temporary_matches(name, target, expected_bytes)
589        {
590            return Err(loader_error(
591                "adoption temporary identity does not match its target and exact body",
592            ));
593        }
594        let Some(observed) = self.inspect_relative(Path::new(name), None)? else {
595            return Ok(false);
596        };
597        if observed != *expected
598            || self.read_relative_bounded(Path::new(name), expected_bytes.len(), Some(expected))?
599                != expected_bytes
600            || self.inspect_relative(Path::new(name), None)?.as_ref() != Some(expected)
601        {
602            return Ok(false);
603        }
604
605        let mut quarantine = None;
606        for attempt in 0..128_u64 {
607            let candidate = adoption_temporary_name("gc", target, expected_bytes, attempt);
608            if candidate == name {
609                continue;
610            }
611            match self.rename_no_replace(OsStr::new(name), OsStr::new(&candidate)) {
612                Ok(()) => {
613                    quarantine = Some(candidate);
614                    break;
615                }
616                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
617                Err(_) => {
618                    return Err(loader_error(
619                        "adoption temporary cannot be quarantined exactly",
620                    ));
621                }
622            }
623        }
624        let quarantine = quarantine
625            .ok_or_else(|| loader_error("adoption temporary quarantine name ceiling exhausted"))?;
626        let moved = self.inspect_relative(Path::new(&quarantine), None)?;
627        let moved_matches = match moved {
628            Some(ref entry) if entry.revision.same_file_after_rename(&expected.revision) => self
629                .read_relative_bounded(Path::new(&quarantine), expected_bytes.len(), Some(entry))
630                .is_ok_and(|bytes| bytes == expected_bytes),
631            _ => false,
632        };
633        if !moved_matches {
634            self.rename_no_replace(OsStr::new(&quarantine), OsStr::new(name))
635                .map_err(|_| {
636                    loader_error(
637                        "adoption temporary quarantine mismatch could not be restored without replacement",
638                    )
639                })?;
640            self.sync_directory()?;
641            return Ok(false);
642        }
643        self.directory.remove_file(&quarantine).map_err(|_| {
644            loader_error("adoption temporary quarantine could not remove the exact publication")
645        })?;
646        self.sync_directory()?;
647        Ok(true)
648    }
649
650    /// Remove one invocation-owned output only while revision and bytes match.
651    pub fn remove_if_matches(
652        &self,
653        name: &str,
654        expected: &LegacyDirectoryEntry,
655        expected_bytes: &[u8],
656    ) -> Result<bool> {
657        validate_direct_component(Path::new(name))?;
658        if name != CONVERSION_JOURNAL
659            || expected.name != OsStr::new(name)
660            || expected_bytes.len() > MAX_LEGACY_ARTIFACT_BYTES
661        {
662            return Err(loader_error(
663                "legacy journal removal identity does not match its publication",
664            ));
665        }
666        let Some(observed) = self.inspect_relative(Path::new(name), None)? else {
667            return Ok(false);
668        };
669        if observed != *expected {
670            return Ok(false);
671        }
672        let bytes =
673            self.read_relative_bounded(Path::new(name), expected_bytes.len(), Some(expected))?;
674        if bytes != expected_bytes {
675            return Ok(false);
676        }
677        let Some(revalidated) = self.inspect_relative(Path::new(name), None)? else {
678            return Ok(false);
679        };
680        if revalidated != *expected {
681            return Ok(false);
682        }
683
684        #[cfg(test)]
685        TEST_BEFORE_JOURNAL_QUARANTINE.with(|hook| {
686            if let Some(hook) = hook.borrow_mut().take() {
687                hook();
688            }
689        });
690
691        // Move the name away atomically without replacement, then prove the
692        // moved inode/body before unlinking it. A swap at the final public
693        // name is therefore quarantined and restored, never deleted.
694        let mut quarantine = None;
695        for attempt in 0..128_u64 {
696            let candidate = adoption_temporary_name("rm", name, expected_bytes, attempt);
697            match self.rename_no_replace(OsStr::new(name), OsStr::new(&candidate)) {
698                Ok(()) => {
699                    quarantine = Some(candidate);
700                    break;
701                }
702                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
703                Err(_) => {
704                    return Err(loader_error("legacy journal cannot be quarantined exactly"));
705                }
706            }
707        }
708        let quarantine = quarantine
709            .ok_or_else(|| loader_error("legacy journal quarantine name ceiling exhausted"))?;
710        let moved = self.inspect_relative(Path::new(&quarantine), None)?;
711        let moved_matches = match moved {
712            Some(ref entry) if entry.revision.same_file_after_rename(&expected.revision) => self
713                .read_relative_bounded(Path::new(&quarantine), expected_bytes.len(), Some(entry))
714                .is_ok_and(|bytes| bytes == expected_bytes),
715            _ => false,
716        };
717        if !moved_matches {
718            self.rename_no_replace(OsStr::new(&quarantine), OsStr::new(name))
719                .map_err(|_| {
720                    loader_error(
721                        "legacy journal quarantine mismatch could not be restored without replacement",
722                    )
723                })?;
724            self.sync_directory()?;
725            return Ok(false);
726        }
727        self.directory.remove_file(&quarantine).map_err(|_| {
728            loader_error("legacy journal quarantine could not remove the exact publication")
729        })?;
730        self.sync_directory()?;
731        Ok(true)
732    }
733
734    #[allow(clippy::needless_return)] // Each cfg branch is a complete platform implementation.
735    fn rename_no_replace(&self, source: &OsStr, destination: &OsStr) -> io::Result<()> {
736        #[cfg(any(
737            target_os = "android",
738            target_os = "linux",
739            target_os = "macos",
740            target_os = "ios",
741            target_os = "redox"
742        ))]
743        {
744            use std::os::fd::AsFd as _;
745
746            return rustix::fs::renameat_with(
747                self.directory.as_fd(),
748                source,
749                self.directory.as_fd(),
750                destination,
751                rustix::fs::RenameFlags::NOREPLACE,
752            )
753            .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()));
754        }
755        #[cfg(windows)]
756        {
757            // Windows rename fails rather than replacing an existing target.
758            return self.directory.rename(source, &self.directory, destination);
759        }
760        #[cfg(not(any(
761            target_os = "android",
762            target_os = "linux",
763            target_os = "macos",
764            target_os = "ios",
765            target_os = "redox",
766            windows
767        )))]
768        {
769            let _ = (source, destination);
770            Err(io::Error::new(
771                io::ErrorKind::Unsupported,
772                "conditional journal quarantine is unsupported on this platform",
773            ))
774        }
775    }
776
777    fn open_relative_directory(&self, relative: &Path) -> Result<Self> {
778        let mut directory = self
779            .directory
780            .try_clone()
781            .map_err(|_| loader_error("legacy directory authority cannot be cloned"))?;
782        let mut display_path = self.display_path.clone();
783        for component in relative_components(relative)? {
784            directory = directory
785                .open_dir_nofollow(&component)
786                .map_err(|_| loader_error("legacy directory descendant cannot be retained"))?;
787            display_path.push(component);
788        }
789        Ok(Self {
790            directory,
791            display_path,
792        })
793    }
794
795    fn open_relative_parent(&self, relative: &Path) -> Result<(Self, OsString)> {
796        let mut components = relative_components(relative)?;
797        let name = components
798            .pop()
799            .ok_or_else(|| loader_error("legacy artifact name is empty"))?;
800        let parent = components.iter().collect::<PathBuf>();
801        Ok((self.open_relative_directory(&parent)?, name))
802    }
803
804    fn sync_directory(&self) -> Result<()> {
805        #[cfg(unix)]
806        {
807            use cap_fs_ext::OpenOptionsMaybeDirExt as _;
808            let mut options = cap_std::fs::OpenOptions::new();
809            options
810                .read(true)
811                .maybe_dir(true)
812                .follow(FollowSymlinks::No);
813            self.directory
814                .open_with(".", &options)
815                .and_then(|directory| directory.sync_all())
816                .map_err(|_| loader_error("legacy directory publication cannot be flushed"))
817        }
818        #[cfg(not(unix))]
819        {
820            Ok(())
821        }
822    }
823}
824
825fn relative_components(path: &Path) -> Result<Vec<OsString>> {
826    let mut names = Vec::new();
827    for component in path.components() {
828        match component {
829            Component::Normal(name) => names.push(name.to_owned()),
830            Component::CurDir => {}
831            Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
832                return Err(loader_error(
833                    "legacy authority path is not a confined relative descendant",
834                ));
835            }
836        }
837    }
838    Ok(names)
839}
840
841fn validate_direct_component(path: &Path) -> Result<()> {
842    let components = relative_components(path)?;
843    if components.len() != 1 || !is_current_platform_direct_child(path.as_os_str()) {
844        return Err(loader_error(
845            "legacy authority publication name is not a safe direct child on this platform",
846        ));
847    }
848    Ok(())
849}
850
851fn adoption_temporary_name(kind: &str, target: &str, contents: &[u8], attempt: u64) -> String {
852    let target_digest = hex_digest(Sha256::digest(target.as_bytes()));
853    let contents_digest = hex_digest(Sha256::digest(contents));
854    format!(".tb-adopt-{kind}-{target_digest}-{contents_digest}-{attempt}.tmp")
855}
856
857fn adoption_temporary_matches(name: &str, target: &str, contents: &[u8]) -> bool {
858    let Some(identity) = name
859        .strip_prefix(".tb-adopt-")
860        .and_then(|value| value.strip_suffix(".tmp"))
861    else {
862        return false;
863    };
864    let mut fields = identity.split('-');
865    let (Some(kind), Some(target_digest), Some(contents_digest), Some(raw_attempt)) =
866        (fields.next(), fields.next(), fields.next(), fields.next())
867    else {
868        return false;
869    };
870    if fields.next().is_some()
871        || !matches!(kind, "pub" | "rm" | "gc")
872        || !is_lower_hex(target_digest, 64)
873        || !is_lower_hex(contents_digest, 64)
874        || (raw_attempt.len() > 1 && raw_attempt.starts_with('0'))
875        || raw_attempt
876            .parse::<u64>()
877            .map_or(true, |attempt| attempt >= 128)
878    {
879        return false;
880    }
881    target_digest == hex_digest(Sha256::digest(target.as_bytes()))
882        && contents_digest == hex_digest(Sha256::digest(contents))
883}
884
885fn lexical_absolute(path: &Path) -> io::Result<PathBuf> {
886    let absolute = if path.is_absolute() {
887        path.to_path_buf()
888    } else {
889        std::env::current_dir()?.join(path)
890    };
891    if !absolute.is_absolute() {
892        return Err(io::Error::new(
893            io::ErrorKind::InvalidInput,
894            "legacy directory path is not absolute",
895        ));
896    }
897    Ok(absolute)
898}
899
900/// Trusted-reader classification of one legacy migration's schema effect.
901#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
902#[serde(rename_all = "snake_case")]
903pub enum LegacySchemaEffect {
904    /// This migration owns an exact immutable snapshot.
905    Snapshot,
906    /// This models-free migration contains only released `RunPython` data work.
907    UnchangedRunPython,
908    /// This released models-free migration has no operations.
909    UnchangedNoop,
910    /// This migration executes only released `CopyAttribute` DML operations.
911    UnchangedCopyAttribute,
912}
913
914#[derive(Clone, Debug, Eq, PartialEq)]
915struct SnapshotAuthority {
916    source: MigrationDependencySpec,
917    schema_hash: String,
918}
919
920#[derive(Clone, Debug, Eq, PartialEq)]
921struct SchemaBinding {
922    effect: LegacySchemaEffect,
923    authority: SnapshotAuthority,
924}
925
926/// Non-executable identity record emitted by the frozen trusted Python reader.
927#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct LegacyAdoptionMetadata {
930    format: String,
931    app_label: String,
932    name: String,
933    #[serde(default)]
934    dependencies: Vec<MigrationDependencySpec>,
935    checksum: String,
936    source_sha256: String,
937    schema_effect: LegacySchemaEffect,
938    schema_source: MigrationDependencySpec,
939    snapshot_schema_hash: String,
940    metadata_digest: String,
941}
942
943impl LegacyAdoptionMetadata {
944    /// Construct and domain-bind one archival record.
945    #[allow(clippy::too_many_arguments)] // The public constructor mirrors the signed record fields.
946    pub fn new(
947        app_label: impl Into<String>,
948        name: impl Into<String>,
949        dependencies: Vec<MigrationDependencySpec>,
950        checksum: impl Into<String>,
951        source_sha256: impl Into<String>,
952        schema_effect: LegacySchemaEffect,
953        schema_source: MigrationDependencySpec,
954        snapshot_schema_hash: impl Into<String>,
955    ) -> Result<Self> {
956        let mut metadata = Self {
957            format: LEGACY_ADOPTION_METADATA_V2.to_owned(),
958            app_label: app_label.into(),
959            name: name.into(),
960            dependencies,
961            checksum: checksum.into(),
962            source_sha256: source_sha256.into(),
963            schema_effect,
964            schema_source,
965            snapshot_schema_hash: snapshot_schema_hash.into(),
966            metadata_digest: String::new(),
967        };
968        metadata.validate_fields()?;
969        metadata.metadata_digest = metadata.expected_digest();
970        Ok(metadata)
971    }
972
973    /// Verify the discriminator, source checksum, and domain-separated digest.
974    pub fn verify(&self) -> Result<()> {
975        self.validate_fields()?;
976        if self.metadata_digest != self.expected_digest() {
977            return Err(loader_error(
978                "legacy adoption metadata digest does not match its identity and dependencies",
979            ));
980        }
981        Ok(())
982    }
983
984    /// Return the Python-source checksum bound by this archive.
985    pub fn checksum(&self) -> &str {
986        &self.checksum
987    }
988
989    /// Return the exact raw Python-source SHA-256 bound by this archive.
990    pub fn source_sha256(&self) -> &str {
991        &self.source_sha256
992    }
993
994    fn validate_fields(&self) -> Result<()> {
995        if self.format != LEGACY_ADOPTION_METADATA_V2 {
996            return Err(loader_error(
997                "legacy adoption metadata uses an unsupported format discriminator",
998            ));
999        }
1000        if self.app_label.is_empty() || self.name.is_empty() {
1001            return Err(loader_error(
1002                "legacy adoption metadata has an empty migration identity",
1003            ));
1004        }
1005        if self.checksum.len() != 16
1006            || !self
1007                .checksum
1008                .bytes()
1009                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1010        {
1011            return Err(loader_error(
1012                "legacy adoption metadata carries a malformed Python-source checksum",
1013            ));
1014        }
1015        if !is_lower_hex(&self.source_sha256, 64) {
1016            return Err(loader_error(
1017                "legacy adoption metadata carries a malformed raw-source digest",
1018            ));
1019        }
1020        if (self.metadata_digest.len() != 64 && !self.metadata_digest.is_empty())
1021            || self
1022                .metadata_digest
1023                .bytes()
1024                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
1025        {
1026            return Err(loader_error(
1027                "legacy adoption metadata carries a malformed metadata digest",
1028            ));
1029        }
1030        if self.schema_source.app_label.is_empty() || self.schema_source.migration_name.is_empty() {
1031            return Err(loader_error(
1032                "legacy adoption metadata has an empty snapshot source identity",
1033            ));
1034        }
1035        if !is_lower_hex(&self.snapshot_schema_hash, 64) {
1036            return Err(loader_error(
1037                "legacy adoption metadata carries a malformed snapshot schema hash",
1038            ));
1039        }
1040        if self.schema_effect == LegacySchemaEffect::Snapshot
1041            && (self.schema_source.app_label != self.app_label
1042                || self.schema_source.migration_name != self.name)
1043        {
1044            return Err(loader_error(
1045                "snapshot schema effect must name the migration that owns the snapshot",
1046            ));
1047        }
1048        if matches!(
1049            self.schema_effect,
1050            LegacySchemaEffect::UnchangedRunPython
1051                | LegacySchemaEffect::UnchangedNoop
1052                | LegacySchemaEffect::UnchangedCopyAttribute
1053        ) && self.dependencies.is_empty()
1054        {
1055            return Err(loader_error(
1056                "unchanged schema effect requires a snapshot-bound dependency",
1057            ));
1058        }
1059        if self.schema_effect == LegacySchemaEffect::UnchangedCopyAttribute {
1060            return Err(loader_error(
1061                "Python-source adoption metadata cannot claim a sidecar-only copy-attribute effect",
1062            ));
1063        }
1064        Ok(())
1065    }
1066
1067    fn expected_digest(&self) -> String {
1068        let mut hasher = Sha256::new();
1069        hasher.update(b"typebridge.migration-adoption-metadata/v2\0");
1070        hash_field(&mut hasher, self.app_label.as_bytes());
1071        hash_field(&mut hasher, self.name.as_bytes());
1072        hash_field(&mut hasher, self.checksum.as_bytes());
1073        hash_field(&mut hasher, self.source_sha256.as_bytes());
1074        let schema_effect: &[u8] = match self.schema_effect {
1075            LegacySchemaEffect::Snapshot => b"snapshot",
1076            LegacySchemaEffect::UnchangedRunPython => b"unchanged_run_python",
1077            LegacySchemaEffect::UnchangedNoop => b"unchanged_noop",
1078            LegacySchemaEffect::UnchangedCopyAttribute => b"unchanged_copy_attribute",
1079        };
1080        hash_field(&mut hasher, schema_effect);
1081        hash_field(&mut hasher, self.schema_source.app_label.as_bytes());
1082        hash_field(&mut hasher, self.schema_source.migration_name.as_bytes());
1083        hash_field(&mut hasher, self.snapshot_schema_hash.as_bytes());
1084        hasher.update(
1085            u64::try_from(self.dependencies.len())
1086                .unwrap_or(u64::MAX)
1087                .to_be_bytes(),
1088        );
1089        for dependency in &self.dependencies {
1090            hash_field(&mut hasher, dependency.app_label.as_bytes());
1091            hash_field(&mut hasher, dependency.migration_name.as_bytes());
1092        }
1093        hex_digest(hasher.finalize())
1094    }
1095
1096    fn schema_binding(&self) -> SchemaBinding {
1097        SchemaBinding {
1098            effect: self.schema_effect,
1099            authority: SnapshotAuthority {
1100                source: self.schema_source.clone(),
1101                schema_hash: self.snapshot_schema_hash.clone(),
1102            },
1103        }
1104    }
1105
1106    fn into_spec(self) -> MigrationSpec {
1107        MigrationSpec {
1108            app_label: self.app_label,
1109            name: self.name,
1110            dependencies: self.dependencies,
1111            operations: Vec::new(),
1112            checksum: Some(self.checksum),
1113            source_sha256: Some(self.source_sha256),
1114            // Archival metadata is deliberately never executable. This value
1115            // is only a graph DTO inside `LegacyAdoptionHistory`.
1116            reversible: false,
1117        }
1118    }
1119}
1120
1121/// Non-executable authority for a migration selected from a released sidecar.
1122///
1123/// Released Python discovery prefers the sidecar and does not import the
1124/// sibling source. This record therefore binds both exact files and the
1125/// effective V1 graph identity/checksum while the native reader independently
1126/// parses the retained sidecar before admitting the graph member.
1127#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1128#[serde(deny_unknown_fields)]
1129pub struct LegacySidecarAdoptionMetadata {
1130    format: String,
1131    source_name: String,
1132    app_label: String,
1133    name: String,
1134    #[serde(default)]
1135    dependencies: Vec<MigrationDependencySpec>,
1136    checksum: String,
1137    #[serde(default)]
1138    sidecar_checksum: Option<String>,
1139    source_sha256: String,
1140    sidecar_sha256: String,
1141    schema_effect: LegacySchemaEffect,
1142    schema_source: MigrationDependencySpec,
1143    snapshot_schema_hash: String,
1144    metadata_digest: String,
1145}
1146
1147impl LegacySidecarAdoptionMetadata {
1148    /// Construct and domain-bind one released-sidecar authority record.
1149    #[allow(clippy::too_many_arguments)]
1150    pub fn new(
1151        source_name: impl Into<String>,
1152        app_label: impl Into<String>,
1153        name: impl Into<String>,
1154        dependencies: Vec<MigrationDependencySpec>,
1155        checksum: impl Into<String>,
1156        sidecar_checksum: Option<String>,
1157        source_sha256: impl Into<String>,
1158        sidecar_sha256: impl Into<String>,
1159        schema_effect: LegacySchemaEffect,
1160        schema_source: MigrationDependencySpec,
1161        snapshot_schema_hash: impl Into<String>,
1162    ) -> Result<Self> {
1163        let mut metadata = Self {
1164            format: LEGACY_SIDECAR_ADOPTION_METADATA_V1.to_owned(),
1165            source_name: source_name.into(),
1166            app_label: app_label.into(),
1167            name: name.into(),
1168            dependencies,
1169            checksum: checksum.into(),
1170            sidecar_checksum,
1171            source_sha256: source_sha256.into(),
1172            sidecar_sha256: sidecar_sha256.into(),
1173            schema_effect,
1174            schema_source,
1175            snapshot_schema_hash: snapshot_schema_hash.into(),
1176            metadata_digest: String::new(),
1177        };
1178        metadata.validate_fields()?;
1179        metadata.metadata_digest = metadata.expected_digest();
1180        Ok(metadata)
1181    }
1182
1183    fn verify(&self) -> Result<()> {
1184        self.validate_fields()?;
1185        if self.metadata_digest != self.expected_digest() {
1186            return Err(loader_error(
1187                "legacy sidecar adoption metadata digest does not match its authority",
1188            ));
1189        }
1190        Ok(())
1191    }
1192
1193    fn validate_fields(&self) -> Result<()> {
1194        if self.format != LEGACY_SIDECAR_ADOPTION_METADATA_V1 {
1195            return Err(loader_error(
1196                "legacy sidecar adoption metadata uses an unsupported format discriminator",
1197            ));
1198        }
1199        if !is_migration_stem(&self.source_name) {
1200            return Err(loader_error(
1201                "legacy sidecar adoption metadata has an invalid source filename stem",
1202            ));
1203        }
1204        if self.app_label.is_empty() || self.name.is_empty() {
1205            return Err(loader_error(
1206                "legacy sidecar adoption metadata has an empty effective identity",
1207            ));
1208        }
1209        if !is_lower_hex(&self.checksum, 16) {
1210            return Err(loader_error(
1211                "legacy sidecar adoption metadata carries a malformed effective checksum",
1212            ));
1213        }
1214        if let Some(sidecar_checksum) = &self.sidecar_checksum
1215            && (!is_lower_hex(sidecar_checksum, 16) || sidecar_checksum != &self.checksum)
1216        {
1217            return Err(loader_error(
1218                "legacy sidecar adoption metadata carries an invalid released checksum binding",
1219            ));
1220        }
1221        if !is_lower_hex(&self.source_sha256, 64) || !is_lower_hex(&self.sidecar_sha256, 64) {
1222            return Err(loader_error(
1223                "legacy sidecar adoption metadata carries a malformed exact-file digest",
1224            ));
1225        }
1226        if !self.metadata_digest.is_empty() && !is_lower_hex(&self.metadata_digest, 64) {
1227            return Err(loader_error(
1228                "legacy sidecar adoption metadata carries a malformed metadata digest",
1229            ));
1230        }
1231        if self.schema_source.app_label.is_empty() || self.schema_source.migration_name.is_empty() {
1232            return Err(loader_error(
1233                "legacy sidecar adoption metadata has an empty snapshot source identity",
1234            ));
1235        }
1236        if !is_lower_hex(&self.snapshot_schema_hash, 64) {
1237            return Err(loader_error(
1238                "legacy sidecar adoption metadata carries a malformed snapshot schema hash",
1239            ));
1240        }
1241        if self.schema_effect == LegacySchemaEffect::Snapshot
1242            && (self.schema_source.app_label != self.app_label
1243                || self.schema_source.migration_name != self.name)
1244        {
1245            return Err(loader_error(
1246                "sidecar snapshot schema effect must name the effective migration identity",
1247            ));
1248        }
1249        if self.schema_effect != LegacySchemaEffect::Snapshot && self.dependencies.is_empty() {
1250            return Err(loader_error(
1251                "unchanged sidecar schema effect requires a snapshot-bound dependency",
1252            ));
1253        }
1254        if self.schema_effect == LegacySchemaEffect::UnchangedRunPython {
1255            return Err(loader_error(
1256                "released sidecar authority cannot claim a Python-only schema effect",
1257            ));
1258        }
1259        Ok(())
1260    }
1261
1262    fn expected_digest(&self) -> String {
1263        let mut hasher = Sha256::new();
1264        hasher.update(b"typebridge.migration-adoption-sidecar/v1\0");
1265        hash_field(&mut hasher, self.source_name.as_bytes());
1266        hash_field(&mut hasher, self.app_label.as_bytes());
1267        hash_field(&mut hasher, self.name.as_bytes());
1268        hash_field(&mut hasher, self.checksum.as_bytes());
1269        hash_field(&mut hasher, self.source_sha256.as_bytes());
1270        hash_field(&mut hasher, self.sidecar_sha256.as_bytes());
1271        match &self.sidecar_checksum {
1272            None => hasher.update([0]),
1273            Some(checksum) => {
1274                hasher.update([1]);
1275                hash_field(&mut hasher, checksum.as_bytes());
1276            }
1277        }
1278        let schema_effect: &[u8] = match self.schema_effect {
1279            LegacySchemaEffect::Snapshot => b"snapshot",
1280            LegacySchemaEffect::UnchangedRunPython => b"unchanged_run_python",
1281            LegacySchemaEffect::UnchangedNoop => b"unchanged_noop",
1282            LegacySchemaEffect::UnchangedCopyAttribute => b"unchanged_copy_attribute",
1283        };
1284        hash_field(&mut hasher, schema_effect);
1285        hash_field(&mut hasher, self.schema_source.app_label.as_bytes());
1286        hash_field(&mut hasher, self.schema_source.migration_name.as_bytes());
1287        hash_field(&mut hasher, self.snapshot_schema_hash.as_bytes());
1288        hasher.update(
1289            u64::try_from(self.dependencies.len())
1290                .unwrap_or(u64::MAX)
1291                .to_be_bytes(),
1292        );
1293        for dependency in &self.dependencies {
1294            hash_field(&mut hasher, dependency.app_label.as_bytes());
1295            hash_field(&mut hasher, dependency.migration_name.as_bytes());
1296        }
1297        hex_digest(hasher.finalize())
1298    }
1299
1300    fn schema_binding(&self) -> SchemaBinding {
1301        SchemaBinding {
1302            effect: self.schema_effect,
1303            authority: SnapshotAuthority {
1304                source: self.schema_source.clone(),
1305                schema_hash: self.snapshot_schema_hash.clone(),
1306            },
1307        }
1308    }
1309
1310    fn into_spec(
1311        self,
1312        mut sidecar: MigrationSpec,
1313        fallback_app_label: &str,
1314    ) -> Result<MigrationSpec> {
1315        let effective_app_label = if sidecar.app_label.is_empty() {
1316            fallback_app_label
1317        } else {
1318            sidecar.app_label.as_str()
1319        };
1320        let effective_name = if sidecar.name.is_empty() {
1321            self.source_name.as_str()
1322        } else {
1323            sidecar.name.as_str()
1324        };
1325        if effective_app_label != self.app_label
1326            || effective_name != self.name
1327            || sidecar.dependencies != self.dependencies
1328            || sidecar.checksum != self.sidecar_checksum
1329        {
1330            return Err(loader_error(
1331                "retained legacy sidecar semantics differ from their adoption metadata",
1332            ));
1333        }
1334        match self.schema_effect {
1335            LegacySchemaEffect::Snapshot => {}
1336            LegacySchemaEffect::UnchangedNoop if sidecar.operations.is_empty() => {}
1337            LegacySchemaEffect::UnchangedCopyAttribute
1338                if !sidecar.operations.is_empty()
1339                    && sidecar.operations.iter().all(|operation| {
1340                        matches!(operation, OperationSpec::CopyAttribute { .. })
1341                    }) => {}
1342            LegacySchemaEffect::UnchangedRunPython
1343            | LegacySchemaEffect::UnchangedNoop
1344            | LegacySchemaEffect::UnchangedCopyAttribute => {
1345                return Err(loader_error(
1346                    "retained legacy sidecar operations contradict their schema-effect binding",
1347                ));
1348            }
1349        }
1350        sidecar.app_label = self.app_label;
1351        sidecar.name = self.name;
1352        sidecar.checksum = Some(self.checksum);
1353        sidecar.source_sha256 = Some(self.source_sha256);
1354        // Adoption never replays an operation, even though the exact sidecar
1355        // remains independently bound and classified above.
1356        sidecar.operations.clear();
1357        Ok(sidecar)
1358    }
1359}
1360
1361/// Checksum-bound evidence for a migration-shaped source that V1 ignored.
1362///
1363/// The released Python loader executes each `NNNN_*.py` module but omits it
1364/// from migration history when the module exposes no public `Migration`
1365/// subclass. The frozen converter records that classification without
1366/// inventing a graph node, and the native adoption reader verifies this
1367/// record before preserving the same omission.
1368#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1369#[serde(deny_unknown_fields)]
1370pub struct LegacyIgnoredSourceMetadata {
1371    format: String,
1372    name: String,
1373    checksum: String,
1374    source_sha256: String,
1375    metadata_digest: String,
1376}
1377
1378impl LegacyIgnoredSourceMetadata {
1379    /// Construct and domain-bind one ignored-source record.
1380    pub fn new(
1381        name: impl Into<String>,
1382        checksum: impl Into<String>,
1383        source_sha256: impl Into<String>,
1384    ) -> Result<Self> {
1385        let mut metadata = Self {
1386            format: LEGACY_IGNORED_SOURCE_METADATA_V1.to_owned(),
1387            name: name.into(),
1388            checksum: checksum.into(),
1389            source_sha256: source_sha256.into(),
1390            metadata_digest: String::new(),
1391        };
1392        metadata.validate_fields()?;
1393        metadata.metadata_digest = metadata.expected_digest();
1394        Ok(metadata)
1395    }
1396
1397    /// Verify the discriminator and domain-separated source identity digest.
1398    pub fn verify(&self) -> Result<()> {
1399        self.validate_fields()?;
1400        if self.metadata_digest != self.expected_digest() {
1401            return Err(loader_error(
1402                "ignored legacy source metadata digest does not match its identity",
1403            ));
1404        }
1405        Ok(())
1406    }
1407
1408    /// Return the filename stem classified by the frozen Python reader.
1409    pub fn name(&self) -> &str {
1410        &self.name
1411    }
1412
1413    /// Return the Python-source checksum bound by this record.
1414    pub fn checksum(&self) -> &str {
1415        &self.checksum
1416    }
1417
1418    /// Return the exact raw Python-source SHA-256 bound by this record.
1419    pub fn source_sha256(&self) -> &str {
1420        &self.source_sha256
1421    }
1422
1423    fn validate_fields(&self) -> Result<()> {
1424        if self.format != LEGACY_IGNORED_SOURCE_METADATA_V1 {
1425            return Err(loader_error(
1426                "ignored legacy source metadata uses an unsupported format discriminator",
1427            ));
1428        }
1429        if !is_migration_stem(&self.name) {
1430            return Err(loader_error(
1431                "ignored legacy source metadata has an invalid migration filename stem",
1432            ));
1433        }
1434        if !is_lower_hex(&self.checksum, 16) {
1435            return Err(loader_error(
1436                "ignored legacy source metadata carries a malformed Python-source checksum",
1437            ));
1438        }
1439        if !is_lower_hex(&self.source_sha256, 64) {
1440            return Err(loader_error(
1441                "ignored legacy source metadata carries a malformed raw-source digest",
1442            ));
1443        }
1444        if !self.metadata_digest.is_empty() && !is_lower_hex(&self.metadata_digest, 64) {
1445            return Err(loader_error(
1446                "ignored legacy source metadata carries a malformed metadata digest",
1447            ));
1448        }
1449        Ok(())
1450    }
1451
1452    fn expected_digest(&self) -> String {
1453        let mut hasher = Sha256::new();
1454        hasher.update(b"typebridge.migration-adoption-ignored-source/v1\0");
1455        hash_field(&mut hasher, self.name.as_bytes());
1456        hash_field(&mut hasher, self.checksum.as_bytes());
1457        hash_field(&mut hasher, self.source_sha256.as_bytes());
1458        hex_digest(hasher.finalize())
1459    }
1460}
1461
1462#[derive(Deserialize)]
1463struct LegacyMetadataFormatProbe {
1464    format: String,
1465}
1466
1467enum LegacySourceMetadata {
1468    Migration(LegacyAdoptionMetadata),
1469    Sidecar(LegacySidecarAdoptionMetadata),
1470    Ignored(LegacyIgnoredSourceMetadata),
1471}
1472
1473/// Checked graph evidence that is intentionally not an executable graph API.
1474pub struct LegacyAdoptionHistory {
1475    graph: MigrationGraph,
1476    directory: LegacyDirectoryAuthority,
1477    directory_capture: LegacyDirectoryCapture,
1478    snapshots: Option<(LegacyDirectoryAuthority, LegacyDirectoryCapture)>,
1479    snapshot_authorities: BTreeMap<(String, String), SnapshotAuthority>,
1480    root_file_digests: BTreeMap<OsString, String>,
1481    consumed_bytes: usize,
1482}
1483
1484impl std::fmt::Debug for LegacyAdoptionHistory {
1485    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1486        formatter
1487            .debug_struct("LegacyAdoptionHistory")
1488            .field("graph", &self.graph)
1489            .field("directory", &self.directory.display_path)
1490            .field("snapshot_authorities", &self.snapshot_authorities)
1491            .finish_non_exhaustive()
1492    }
1493}
1494
1495impl LegacyAdoptionHistory {
1496    /// Borrow the frozen graph for continuity/frontier validation only.
1497    pub const fn graph(&self) -> &MigrationGraph {
1498        &self.graph
1499    }
1500
1501    /// Return the checked legacy directory that owns this evidence.
1502    pub fn directory(&self) -> &Path {
1503        &self.directory.display_path
1504    }
1505
1506    /// Re-run the complete bounded authority verification at a use boundary.
1507    ///
1508    /// Callers that await external state or are about to publish/apply a
1509    /// durable checkpoint must invoke this after the await and immediately
1510    /// before consuming [`Self::graph`]. This re-enumerates filtered history
1511    /// membership and re-verifies the authoritative snapshot bytes and hashes,
1512    /// including in-place child-file edits that directory metadata cannot bind.
1513    pub fn require_unchanged(&self) -> Result<()> {
1514        reconstruct_legacy_head(self).map(|_| ())
1515    }
1516
1517    /// Revalidate and require the exact previously reconstructed head bytes.
1518    pub fn require_unchanged_head(&self, expected: &VerifiedLegacyHead) -> Result<()> {
1519        let observed = reconstruct_legacy_head(self)?;
1520        if &observed != expected {
1521            return Err(loader_error(
1522                "legacy reconstructed head changed after its checked capture",
1523            ));
1524        }
1525        Ok(())
1526    }
1527
1528    fn require_retained_membership(&self) -> Result<()> {
1529        self.directory
1530            .require_recognized_root_capture(&self.directory_capture)?;
1531        let mut aggregate = 0usize;
1532        for (name, expected_digest) in &self.root_file_digests {
1533            let entry = self
1534                .directory_capture
1535                .entries
1536                .iter()
1537                .find(|entry| &entry.name == name)
1538                .ok_or_else(|| loader_error("retained root digest entry disappeared"))?;
1539            let bytes = read_bounded(
1540                &self.directory,
1541                entry,
1542                MAX_LEGACY_ARTIFACT_BYTES,
1543                &mut aggregate,
1544            )?;
1545            if hex_digest(Sha256::digest(&bytes)) != *expected_digest {
1546                return Err(loader_error(
1547                    "recognized legacy root file body changed after checked capture",
1548                ));
1549            }
1550        }
1551        if let Some((snapshots, capture)) = &self.snapshots {
1552            snapshots.require_snapshot_version_capture(capture)?;
1553        }
1554        Ok(())
1555    }
1556
1557    fn heads(&self) -> Result<Vec<&MigrationSpec>> {
1558        let depended_on = self
1559            .graph
1560            .migrations
1561            .iter()
1562            .flat_map(|migration| {
1563                migration.dependencies.iter().map(|dependency| {
1564                    (
1565                        dependency.app_label.clone(),
1566                        dependency.migration_name.clone(),
1567                    )
1568                })
1569            })
1570            .collect::<BTreeSet<_>>();
1571        let mut heads = self
1572            .graph
1573            .migrations
1574            .iter()
1575            .filter(|migration| {
1576                !depended_on.contains(&(migration.app_label.clone(), migration.name.clone()))
1577            })
1578            .collect::<Vec<_>>();
1579        if heads.is_empty() {
1580            return Err(loader_error(
1581                "legacy adoption history has no graph head after validation",
1582            ));
1583        }
1584        heads.sort_by(|left, right| {
1585            (&left.app_label, &left.name).cmp(&(&right.app_label, &right.name))
1586        });
1587        Ok(heads)
1588    }
1589}
1590
1591/// Independently reconstructed legacy head loaded from a verified snapshot.
1592#[derive(Clone, Debug, Eq, PartialEq)]
1593pub struct VerifiedLegacyHead {
1594    source_migration: String,
1595    schema_typeql: String,
1596}
1597
1598impl VerifiedLegacyHead {
1599    /// Return the migration whose immutable snapshot reconstructed this head.
1600    pub fn source_migration(&self) -> &str {
1601        &self.source_migration
1602    }
1603
1604    /// Return the exact verified snapshot TypeQL bytes as UTF-8 text.
1605    pub fn schema_typeql(&self) -> &str {
1606        &self.schema_typeql
1607    }
1608}
1609
1610fn parse_legacy_source_metadata(bytes: &[u8], path: &Path) -> Result<LegacySourceMetadata> {
1611    let probe: LegacyMetadataFormatProbe = serde_json::from_slice(bytes).map_err(|error| {
1612        loader_error(format!(
1613            "failed to inspect adoption metadata {}: {error}",
1614            path.display()
1615        ))
1616    })?;
1617    match probe.format.as_str() {
1618        LEGACY_ADOPTION_METADATA_V2 => serde_json::from_slice(bytes)
1619            .map(LegacySourceMetadata::Migration)
1620            .map_err(|error| {
1621                loader_error(format!(
1622                    "failed to parse adoption metadata {}: {error}",
1623                    path.display()
1624                ))
1625            }),
1626        LEGACY_SIDECAR_ADOPTION_METADATA_V1 => serde_json::from_slice(bytes)
1627            .map(LegacySourceMetadata::Sidecar)
1628            .map_err(|error| {
1629                loader_error(format!(
1630                    "failed to parse sidecar adoption metadata {}: {error}",
1631                    path.display()
1632                ))
1633            }),
1634        LEGACY_IGNORED_SOURCE_METADATA_V1 => serde_json::from_slice(bytes)
1635            .map(LegacySourceMetadata::Ignored)
1636            .map_err(|error| {
1637                loader_error(format!(
1638                    "failed to parse ignored-source adoption metadata {}: {error}",
1639                    path.display()
1640                ))
1641            }),
1642        _ => Err(loader_error(
1643            "legacy adoption metadata uses an unsupported format discriminator",
1644        )),
1645    }
1646}
1647
1648/// Load identity/dependency/checksum evidence for an already-applied history.
1649///
1650/// Every Python file requires a non-executable `NNNN_name.adoption.json`
1651/// archive produced by the frozen trusted reader. A V1 migration graph member
1652/// carries full graph and schema-authority metadata; a released sidecar-backed
1653/// member carries a distinct record binding both exact files and its normalized
1654/// effective graph semantics; a source with no public `Migration` subclass
1655/// carries a smaller ignored-source record and remains excluded from the graph.
1656pub fn load_adoption_history(directory: &Path) -> Result<LegacyAdoptionHistory> {
1657    let directory = LegacyDirectoryAuthority::open_root(directory)?;
1658    load_adoption_history_in(directory)
1659}
1660
1661fn load_adoption_history_in(directory: LegacyDirectoryAuthority) -> Result<LegacyAdoptionHistory> {
1662    let fallback_app_label = directory
1663        .display_path
1664        .file_name()
1665        .and_then(OsStr::to_str)
1666        .filter(|name| !name.is_empty())
1667        .ok_or_else(|| loader_error("legacy migration directory has no UTF-8 app-label name"))?
1668        .to_owned();
1669    let mut directory_capture = directory.capture()?;
1670    let mut python = BTreeMap::<String, LegacyDirectoryEntry>::new();
1671    let mut sidecars = BTreeMap::<String, LegacyDirectoryEntry>::new();
1672    let mut archives = BTreeMap::<String, LegacyDirectoryEntry>::new();
1673
1674    if directory_capture
1675        .entries
1676        .iter()
1677        .any(|entry| entry.name == OsStr::new(CONVERSION_JOURNAL))
1678    {
1679        return Err(loader_error(
1680            "legacy adoption conversion journal is present; resume the trusted converter",
1681        ));
1682    }
1683
1684    for entry in &directory_capture.entries {
1685        let Some(name) = entry.name.to_str() else {
1686            if looks_migration_shaped_bytes(&entry.name) {
1687                return Err(loader_error(
1688                    "recognized legacy migration filename is not valid UTF-8",
1689                ));
1690            }
1691            continue;
1692        };
1693        if let Some(stem) = name.strip_suffix(ADOPTION_SUFFIX) {
1694            if is_migration_stem(stem) {
1695                require_regular(entry)?;
1696                archives.insert(stem.to_owned(), entry.clone());
1697            }
1698            continue;
1699        }
1700        let Some((stem, extension)) = name.rsplit_once('.') else {
1701            continue;
1702        };
1703        if !is_migration_stem(stem) {
1704            continue;
1705        }
1706        match extension {
1707            "py" => {
1708                require_regular(entry)?;
1709                python.insert(stem.to_owned(), entry.clone());
1710            }
1711            "json" => {
1712                sidecars.insert(stem.to_owned(), entry.clone());
1713            }
1714            _ => {}
1715        }
1716    }
1717
1718    for stem in archives.keys() {
1719        if !python.contains_key(stem) {
1720            return Err(loader_error(format!(
1721                "legacy metadata {stem} has no Python source to verify"
1722            )));
1723        }
1724    }
1725
1726    let mut retained_names = python
1727        .values()
1728        .chain(sidecars.values())
1729        .chain(archives.values())
1730        .map(|entry| entry.name.clone())
1731        .collect::<BTreeSet<_>>();
1732    retained_names.insert(OsString::from("snapshots"));
1733    directory_capture
1734        .entries
1735        .retain(|entry| retained_names.contains(&entry.name));
1736
1737    let mut aggregate = 0usize;
1738    let mut migrations = Vec::with_capacity(python.len());
1739    let mut schema_bindings = BTreeMap::new();
1740    let mut root_file_digests = BTreeMap::<OsString, String>::new();
1741    for (stem, py_entry) in python {
1742        let py_bytes = read_bounded(
1743            &directory,
1744            &py_entry,
1745            MAX_LEGACY_ARTIFACT_BYTES,
1746            &mut aggregate,
1747        )?;
1748        let source_sha256 = hex_digest(Sha256::digest(&py_bytes));
1749        root_file_digests.insert(py_entry.name.clone(), source_sha256.clone());
1750        let source_metadata = if let Some(archive_entry) = archives.get(&stem) {
1751            let bytes = read_bounded(
1752                &directory,
1753                archive_entry,
1754                MAX_LEGACY_ARTIFACT_BYTES,
1755                &mut aggregate,
1756            )?;
1757            root_file_digests.insert(
1758                archive_entry.name.clone(),
1759                hex_digest(Sha256::digest(&bytes)),
1760            );
1761            parse_legacy_source_metadata(&bytes, &directory.display_path.join(&archive_entry.name))?
1762        } else {
1763            return Err(loader_error(format!(
1764                "migration {stem} has no checksum-bound adoption metadata; run `python -m type_bridge.migration.sidecar {}` through the trusted Python environment",
1765                directory.display_path.display()
1766            )));
1767        };
1768
1769        match source_metadata {
1770            LegacySourceMetadata::Migration(archive) => {
1771                archive.verify()?;
1772                if sidecars.contains_key(&stem) {
1773                    return Err(loader_error(format!(
1774                        "source-authoritative adoption metadata for {stem} conflicts with a retained JSON sidecar"
1775                    )));
1776                }
1777                if archive.source_sha256() != source_sha256 {
1778                    return Err(loader_error(format!(
1779                        "adoption metadata drift detected for {stem}: raw Python source digest differs"
1780                    )));
1781                }
1782                if archive.name != stem {
1783                    return Err(loader_error(format!(
1784                        "legacy metadata identity {} does not match filename stem {stem}",
1785                        archive.name
1786                    )));
1787                }
1788                if archive.app_label != fallback_app_label {
1789                    return Err(loader_error(format!(
1790                        "legacy metadata owner {} does not match migration directory app label {fallback_app_label}",
1791                        archive.app_label
1792                    )));
1793                }
1794                schema_bindings.insert(
1795                    (archive.app_label.clone(), archive.name.clone()),
1796                    archive.schema_binding(),
1797                );
1798                migrations.push(archive.into_spec());
1799            }
1800            LegacySourceMetadata::Sidecar(archive) => {
1801                archive.verify()?;
1802                if archive.source_sha256 != source_sha256 {
1803                    return Err(loader_error(format!(
1804                        "sidecar adoption metadata drift detected for {stem}: raw Python source digest differs"
1805                    )));
1806                }
1807                if archive.source_name != stem {
1808                    return Err(loader_error(format!(
1809                        "sidecar adoption source identity {} does not match filename stem {stem}",
1810                        archive.source_name
1811                    )));
1812                }
1813                let sidecar_entry = sidecars.get(&stem).ok_or_else(|| {
1814                    loader_error(format!(
1815                        "sidecar-authoritative migration {stem} has no retained JSON sidecar"
1816                    ))
1817                })?;
1818                let sidecar_bytes = read_bounded(
1819                    &directory,
1820                    sidecar_entry,
1821                    MAX_LEGACY_ARTIFACT_BYTES,
1822                    &mut aggregate,
1823                )?;
1824                let sidecar_sha256 = hex_digest(Sha256::digest(&sidecar_bytes));
1825                if archive.sidecar_sha256 != sidecar_sha256 {
1826                    return Err(loader_error(format!(
1827                        "sidecar adoption metadata drift detected for {stem}: exact JSON digest differs"
1828                    )));
1829                }
1830                root_file_digests.insert(sidecar_entry.name.clone(), sidecar_sha256);
1831                let sidecar: MigrationSpec =
1832                    serde_json::from_slice(&sidecar_bytes).map_err(|error| {
1833                        loader_error(format!(
1834                            "failed to parse retained sidecar {}: {error}",
1835                            directory.display_path.join(&sidecar_entry.name).display()
1836                        ))
1837                    })?;
1838                let key = (archive.app_label.clone(), archive.name.clone());
1839                schema_bindings.insert(key, archive.schema_binding());
1840                migrations.push(archive.into_spec(sidecar, &fallback_app_label)?);
1841            }
1842            LegacySourceMetadata::Ignored(ignored) => {
1843                ignored.verify()?;
1844                if sidecars.contains_key(&stem) {
1845                    return Err(loader_error(format!(
1846                        "ignored-source adoption metadata for {stem} conflicts with a retained JSON sidecar"
1847                    )));
1848                }
1849                if ignored.source_sha256() != source_sha256 {
1850                    return Err(loader_error(format!(
1851                        "ignored-source adoption metadata drift detected for {stem}: raw Python source digest differs"
1852                    )));
1853                }
1854                if ignored.name() != stem {
1855                    return Err(loader_error(format!(
1856                        "ignored-source metadata identity {} does not match filename stem {stem}",
1857                        ignored.name()
1858                    )));
1859                }
1860            }
1861        }
1862    }
1863    for entry in sidecars.values() {
1864        if entry.is_file() && !root_file_digests.contains_key(&entry.name) {
1865            let bytes = read_bounded(&directory, entry, MAX_LEGACY_ARTIFACT_BYTES, &mut aggregate)?;
1866            root_file_digests.insert(entry.name.clone(), hex_digest(Sha256::digest(&bytes)));
1867        }
1868    }
1869
1870    let graph = MigrationGraph { migrations };
1871    let errors = validate_graph(&graph, &[]);
1872    if !errors.is_empty() {
1873        return Err(MigrationError::Planning { errors });
1874    }
1875    let snapshot_authorities = resolve_schema_bindings(&graph, &schema_bindings)?;
1876    let snapshots = if let Some(entry) = directory_capture
1877        .entries
1878        .iter()
1879        .find(|entry| entry.name == OsStr::new("snapshots"))
1880    {
1881        let retained = directory.open_child(entry)?;
1882        let mut capture = retained.capture()?;
1883        capture
1884            .entries
1885            .retain(|candidate| candidate.name.to_str().is_some_and(is_snapshot_version));
1886        Some((retained, capture))
1887    } else {
1888        None
1889    };
1890    if let Some((snapshots, capture)) = &snapshots {
1891        snapshots.require_snapshot_version_capture(capture)?;
1892    }
1893    directory.require_recognized_root_capture(&directory_capture)?;
1894    Ok(LegacyAdoptionHistory {
1895        graph,
1896        directory,
1897        directory_capture,
1898        snapshots,
1899        snapshot_authorities,
1900        root_file_digests,
1901        consumed_bytes: aggregate,
1902    })
1903}
1904
1905fn resolve_schema_bindings(
1906    graph: &MigrationGraph,
1907    bindings: &BTreeMap<(String, String), SchemaBinding>,
1908) -> Result<BTreeMap<(String, String), SnapshotAuthority>> {
1909    let mut resolved = BTreeMap::new();
1910    let mut pending = BTreeMap::<
1911        (String, String),
1912        (LegacySchemaEffect, SnapshotAuthority, Vec<(String, String)>),
1913    >::new();
1914    let mut dependents = BTreeMap::<(String, String), Vec<(String, String)>>::new();
1915
1916    for migration in &graph.migrations {
1917        let key = (migration.app_label.clone(), migration.name.clone());
1918        let binding = bindings.get(&key).ok_or_else(|| {
1919            loader_error(format!(
1920                "legacy migration {}.{} has no schema-effect binding",
1921                migration.app_label, migration.name
1922            ))
1923        })?;
1924        match binding.effect {
1925            LegacySchemaEffect::Snapshot => {
1926                resolved.insert(key, binding.authority.clone());
1927            }
1928            LegacySchemaEffect::UnchangedRunPython
1929            | LegacySchemaEffect::UnchangedNoop
1930            | LegacySchemaEffect::UnchangedCopyAttribute => {
1931                let dependencies = migration
1932                    .dependencies
1933                    .iter()
1934                    .map(|dependency| {
1935                        (
1936                            dependency.app_label.clone(),
1937                            dependency.migration_name.clone(),
1938                        )
1939                    })
1940                    .collect::<Vec<_>>();
1941                if dependencies.is_empty() {
1942                    return Err(loader_error(format!(
1943                        "unchanged migration {}.{} has no parent authority",
1944                        migration.app_label, migration.name
1945                    )));
1946                }
1947                for dependency in &dependencies {
1948                    dependents
1949                        .entry(dependency.clone())
1950                        .or_default()
1951                        .push(key.clone());
1952                }
1953                pending.insert(
1954                    key,
1955                    (binding.effect, binding.authority.clone(), dependencies),
1956                );
1957            }
1958        }
1959    }
1960
1961    let mut unresolved_parent_counts = pending
1962        .iter()
1963        .map(|(key, (_, _, dependencies))| {
1964            (
1965                key.clone(),
1966                dependencies
1967                    .iter()
1968                    .filter(|dependency| !resolved.contains_key(*dependency))
1969                    .count(),
1970            )
1971        })
1972        .collect::<BTreeMap<_, _>>();
1973    let mut ready = unresolved_parent_counts
1974        .iter()
1975        .filter_map(|(key, count)| (*count == 0).then_some(key.clone()))
1976        .collect::<BTreeSet<_>>();
1977
1978    while let Some(key) = ready.pop_first() {
1979        let (_, bound_authority, dependencies) = pending
1980            .get(&key)
1981            .ok_or_else(|| loader_error("legacy schema binding work item disappeared"))?;
1982        let parents = dependencies
1983            .iter()
1984            .map(|dependency| {
1985                resolved.get(dependency).ok_or_else(|| {
1986                    loader_error("legacy schema binding parent was not resolved deterministically")
1987                })
1988            })
1989            .collect::<Result<Vec<_>>>()?;
1990        let (parent, remaining_parents) = parents.split_first().ok_or_else(|| {
1991            loader_error(format!(
1992                "unchanged migration {}.{} has no parent authority",
1993                key.0, key.1
1994            ))
1995        })?;
1996        let parent = *parent;
1997        if remaining_parents
1998            .iter()
1999            .any(|authority| authority.schema_hash != parent.schema_hash)
2000        {
2001            return Err(loader_error(format!(
2002                "unchanged migration {}.{} merges divergent snapshot authorities with different schema hashes",
2003                key.0, key.1
2004            )));
2005        }
2006        let canonical_parent = parents
2007            .iter()
2008            .copied()
2009            .min_by(|left, right| {
2010                (
2011                    &left.source.app_label,
2012                    &left.source.migration_name,
2013                    &left.schema_hash,
2014                )
2015                    .cmp(&(
2016                        &right.source.app_label,
2017                        &right.source.migration_name,
2018                        &right.schema_hash,
2019                    ))
2020            })
2021            .ok_or_else(|| loader_error("legacy schema binding parent set disappeared"))?;
2022        if bound_authority != canonical_parent {
2023            return Err(loader_error(format!(
2024                "unchanged migration {}.{} does not bind its deterministic parent snapshot authority",
2025                key.0, key.1
2026            )));
2027        }
2028        resolved.insert(key.clone(), bound_authority.clone());
2029        if let Some(children) = dependents.get(&key) {
2030            for child in children {
2031                let remaining = unresolved_parent_counts.get_mut(child).ok_or_else(|| {
2032                    loader_error("legacy schema binding dependency count disappeared")
2033                })?;
2034                *remaining = remaining.saturating_sub(1);
2035                if *remaining == 0 {
2036                    ready.insert(child.clone());
2037                }
2038            }
2039        }
2040    }
2041    if resolved.len() != graph.migrations.len() {
2042        return Err(loader_error(
2043            "legacy schema-effect bindings could not be resolved over the validated graph",
2044        ));
2045    }
2046    require_unambiguous_snapshot_owners(&resolved)?;
2047    Ok(resolved)
2048}
2049
2050fn require_unambiguous_snapshot_owners(
2051    authorities: &BTreeMap<(String, String), SnapshotAuthority>,
2052) -> Result<()> {
2053    let mut owners_by_source = BTreeMap::<&str, BTreeSet<&str>>::new();
2054    for authority in authorities.values() {
2055        owners_by_source
2056            .entry(authority.source.migration_name.as_str())
2057            .or_default()
2058            .insert(authority.source.app_label.as_str());
2059    }
2060    if let Some((source, owners)) = owners_by_source
2061        .into_iter()
2062        .find(|(_, owners)| owners.len() > 1)
2063    {
2064        return Err(loader_error(format!(
2065            "legacy snapshot source {source} is ambiguous across app labels: {}",
2066            owners.into_iter().collect::<Vec<_>>().join(", ")
2067        )));
2068    }
2069    Ok(())
2070}
2071
2072struct SnapshotManifest {
2073    version: String,
2074    source_migration: String,
2075    schema_hash: String,
2076    file_hashes: BTreeMap<String, String>,
2077}
2078
2079fn parse_snapshot_manifest(bytes: &[u8], path: &Path) -> Result<SnapshotManifest> {
2080    // Frozen Python V1 used `json.loads`: unknown members are ignored by the
2081    // snapshot reader, duplicate object keys are last-value-wins, integers are
2082    // arbitrary precision, and the non-standard NaN/Infinity constants are
2083    // admitted. Replace only Python's three bare value constants outside
2084    // strings with same-width JSON numbers; this retains offsets without
2085    // accepting JSON5 syntax. Values are first retained as raw spans: serde's
2086    // raw-value scanner skips nested ignored members iteratively, so released
2087    // manifests deeper than serde_json's recursive Value ceiling remain valid
2088    // without exposing this bounded reader to recursive allocation or drop.
2089    // BTreeMap insertion also preserves Python's last-key-wins behavior before
2090    // the four authoritative members are type-checked. Python also retains
2091    // escaped lone surrogates in object keys, while Rust strings cannot
2092    // represent them. Normalize only lone surrogate escapes in root keys to a
2093    // non-ASCII scalar: those keys cannot name an authoritative ASCII member,
2094    // so normalization collisions stay among ignored metadata keys and their
2095    // values remain ignored as raw spans.
2096    let mut python_compatible = sanitize_python_json_constants(bytes);
2097    sanitize_python_json_root_key_surrogates(&mut python_compatible);
2098    let mut fields: BTreeMap<String, Box<serde_json::value::RawValue>> =
2099        serde_json::from_slice(&python_compatible).map_err(|error| {
2100            loader_error(format!(
2101                "failed to parse snapshot manifest {}: {error}",
2102                path.display()
2103            ))
2104        })?;
2105    let version = parse_snapshot_string_field(&mut fields, "version", path)?;
2106    let source_migration = parse_snapshot_string_field(&mut fields, "source_migration", path)?;
2107    let schema_hash = parse_snapshot_string_field(&mut fields, "schema_hash", path)?;
2108    let file_hashes_raw = fields.remove("file_hashes").ok_or_else(|| {
2109        loader_error(format!(
2110            "failed to validate snapshot manifest {}: missing field `file_hashes`",
2111            path.display()
2112        ))
2113    })?;
2114    let raw_file_hashes: BTreeMap<String, Box<serde_json::value::RawValue>> =
2115        serde_json::from_str(file_hashes_raw.get()).map_err(|error| {
2116            loader_error(format!(
2117                "failed to validate snapshot manifest {}: file_hashes: {error}",
2118                path.display()
2119            ))
2120        })?;
2121    let mut file_hashes = BTreeMap::new();
2122    for (name, raw_hash) in raw_file_hashes {
2123        let hash = serde_json::from_str(raw_hash.get()).map_err(|error| {
2124            loader_error(format!(
2125                "failed to validate snapshot manifest {}: file_hashes[{name:?}]: {error}",
2126                path.display()
2127            ))
2128        })?;
2129        file_hashes.insert(name, hash);
2130    }
2131    Ok(SnapshotManifest {
2132        version,
2133        source_migration,
2134        schema_hash,
2135        file_hashes,
2136    })
2137}
2138
2139fn parse_snapshot_string_field(
2140    fields: &mut BTreeMap<String, Box<serde_json::value::RawValue>>,
2141    name: &'static str,
2142    path: &Path,
2143) -> Result<String> {
2144    let raw = fields.remove(name).ok_or_else(|| {
2145        loader_error(format!(
2146            "failed to validate snapshot manifest {}: missing field `{name}`",
2147            path.display()
2148        ))
2149    })?;
2150    serde_json::from_str(raw.get()).map_err(|error| {
2151        loader_error(format!(
2152            "failed to validate snapshot manifest {}: {name}: {error}",
2153            path.display()
2154        ))
2155    })
2156}
2157
2158fn sanitize_python_json_constants(bytes: &[u8]) -> Vec<u8> {
2159    const CONSTANTS: [(&[u8], &[u8]); 3] = [
2160        (b"-Infinity", b"-0.000000"),
2161        (b"Infinity", b"0.000000"),
2162        (b"NaN", b"0.0"),
2163    ];
2164    let mut sanitized = bytes.to_vec();
2165    let mut offset = 0usize;
2166    let mut in_string = false;
2167    let mut escaped = false;
2168    while offset < bytes.len() {
2169        let byte = bytes[offset];
2170        if in_string {
2171            if escaped {
2172                escaped = false;
2173            } else if byte == b'\\' {
2174                escaped = true;
2175            } else if byte == b'"' {
2176                in_string = false;
2177            }
2178            offset += 1;
2179            continue;
2180        }
2181        if byte == b'"' {
2182            in_string = true;
2183            offset += 1;
2184            continue;
2185        }
2186        let mut replaced = false;
2187        for (constant, replacement) in CONSTANTS {
2188            let end = offset.saturating_add(constant.len());
2189            if bytes.get(offset..end) == Some(constant)
2190                && is_json_value_boundary_before(bytes, offset)
2191                && is_json_value_boundary_after(bytes, end)
2192            {
2193                sanitized[offset..end].copy_from_slice(replacement);
2194                offset = end;
2195                replaced = true;
2196                break;
2197            }
2198        }
2199        if !replaced {
2200            offset += 1;
2201        }
2202    }
2203    sanitized
2204}
2205
2206fn sanitize_python_json_root_key_surrogates(bytes: &mut [u8]) {
2207    let mut offset = 0usize;
2208    let mut object_depth = 0usize;
2209    let mut array_depth = 0usize;
2210    while offset < bytes.len() {
2211        match bytes[offset] {
2212            b'{' => {
2213                object_depth = object_depth.saturating_add(1);
2214                offset += 1;
2215            }
2216            b'}' => {
2217                object_depth = object_depth.saturating_sub(1);
2218                offset += 1;
2219            }
2220            b'[' => {
2221                array_depth = array_depth.saturating_add(1);
2222                offset += 1;
2223            }
2224            b']' => {
2225                array_depth = array_depth.saturating_sub(1);
2226                offset += 1;
2227            }
2228            b'"' => {
2229                let Some(end) = scan_json_string_end(bytes, offset) else {
2230                    return;
2231                };
2232                let mut following = end + 1;
2233                while bytes
2234                    .get(following)
2235                    .is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
2236                {
2237                    following += 1;
2238                }
2239                if object_depth == 1 && array_depth == 0 && bytes.get(following) == Some(&b':') {
2240                    replace_lone_surrogate_escapes(bytes, offset + 1, end);
2241                }
2242                offset = end + 1;
2243            }
2244            _ => offset += 1,
2245        }
2246    }
2247}
2248
2249fn scan_json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
2250    let mut offset = start + 1;
2251    while offset < bytes.len() {
2252        match bytes[offset] {
2253            b'"' => return Some(offset),
2254            b'\\' => offset = offset.saturating_add(2),
2255            _ => offset += 1,
2256        }
2257    }
2258    None
2259}
2260
2261fn parse_json_hex_code_unit(bytes: &[u8]) -> Option<u16> {
2262    if bytes.len() != 4 {
2263        return None;
2264    }
2265    bytes.iter().try_fold(0u16, |value, byte| {
2266        let digit = match byte {
2267            b'0'..=b'9' => u16::from(byte - b'0'),
2268            b'a'..=b'f' => u16::from(byte - b'a') + 10,
2269            b'A'..=b'F' => u16::from(byte - b'A') + 10,
2270            _ => return None,
2271        };
2272        Some((value << 4) | digit)
2273    })
2274}
2275
2276fn replace_lone_surrogate_escapes(bytes: &mut [u8], start: usize, end: usize) {
2277    let mut offset = start;
2278    while offset < end {
2279        if bytes[offset] != b'\\' {
2280            offset += 1;
2281            continue;
2282        }
2283        let Some(code_unit) = bytes
2284            .get(offset + 2..offset + 6)
2285            .filter(|_| bytes.get(offset + 1) == Some(&b'u'))
2286            .and_then(parse_json_hex_code_unit)
2287        else {
2288            offset = offset.saturating_add(2);
2289            continue;
2290        };
2291        if (0xd800..=0xdbff).contains(&code_unit) {
2292            let paired_low = bytes
2293                .get(offset + 8..offset + 12)
2294                .filter(|_| {
2295                    bytes.get(offset + 6) == Some(&b'\\') && bytes.get(offset + 7) == Some(&b'u')
2296                })
2297                .and_then(parse_json_hex_code_unit)
2298                .is_some_and(|low| (0xdc00..=0xdfff).contains(&low));
2299            if paired_low {
2300                offset += 12;
2301                continue;
2302            }
2303        }
2304        if (0xd800..=0xdfff).contains(&code_unit) {
2305            bytes[offset + 2..offset + 6].copy_from_slice(b"fffd");
2306        }
2307        offset += 6;
2308    }
2309}
2310
2311fn is_json_value_boundary_before(bytes: &[u8], offset: usize) -> bool {
2312    offset == 0
2313        || matches!(
2314            bytes[offset - 1],
2315            b'[' | b'{' | b',' | b':' | b' ' | b'\t' | b'\r' | b'\n'
2316        )
2317}
2318
2319fn is_json_value_boundary_after(bytes: &[u8], offset: usize) -> bool {
2320    offset == bytes.len()
2321        || matches!(
2322            bytes[offset],
2323            b']' | b'}' | b',' | b' ' | b'\t' | b'\r' | b'\n'
2324        )
2325}
2326
2327/// Reconstruct the independently verified schema at a checked legacy frontier.
2328///
2329/// A released legacy graph may have more than one applied head. Every head is
2330/// resolved to and reconstructed from its own checksum-bound snapshot
2331/// authority. Adoption proceeds only when those independently verified
2332/// authorities have the same schema hash and exact schema bytes; the complete
2333/// head set remains in [`LegacyAdoptionHistory::graph`] for frontier import.
2334pub fn reconstruct_legacy_head(history: &LegacyAdoptionHistory) -> Result<VerifiedLegacyHead> {
2335    require_unambiguous_snapshot_owners(&history.snapshot_authorities)?;
2336    let heads = history.heads()?;
2337    history.require_retained_membership()?;
2338    let (snapshots, snapshot_capture) = history.snapshots.as_ref().ok_or_else(|| {
2339        loader_error("legacy snapshots authority was absent when adoption history was retained")
2340    })?;
2341    let needed_authorities = history
2342        .snapshot_authorities
2343        .values()
2344        .map(|authority| {
2345            (
2346                authority.source.app_label.clone(),
2347                authority.source.migration_name.clone(),
2348                authority.schema_hash.clone(),
2349            )
2350        })
2351        .collect::<BTreeSet<_>>();
2352    let mut expected_hashes_by_source = BTreeMap::<String, BTreeSet<String>>::new();
2353    for (_, source, schema_hash) in &needed_authorities {
2354        expected_hashes_by_source
2355            .entry(source.clone())
2356            .or_default()
2357            .insert(schema_hash.clone());
2358    }
2359    let mut available = BTreeMap::<(String, String), VerifiedLegacyHead>::new();
2360    let mut verified_snapshot_captures = Vec::new();
2361    let mut scanned_snapshot_manifests = Vec::new();
2362    let mut aggregate = history.consumed_bytes;
2363    let mut nested_entries = snapshot_capture.entries.len();
2364    for entry in &snapshot_capture.entries {
2365        let Some(version) = entry.name.to_str() else {
2366            continue;
2367        };
2368        if !is_snapshot_version(version) {
2369            continue;
2370        }
2371        if nested_entries == MAX_LEGACY_DIRECTORY_ENTRIES {
2372            return Err(loader_error(
2373                "legacy snapshot tree exceeds the entry ceiling",
2374            ));
2375        }
2376        nested_entries += 1;
2377        let snapshot = snapshots.open_child(entry)?;
2378        let snapshot_before = snapshot.revision()?;
2379        let manifest_entry = snapshot
2380            .inspect_relative(Path::new(SNAPSHOT_MANIFEST), None)?
2381            .ok_or_else(|| loader_error("legacy snapshot manifest is absent"))?;
2382        require_regular(&manifest_entry)?;
2383        let bytes = read_bounded(
2384            &snapshot,
2385            &manifest_entry,
2386            MAX_LEGACY_ARTIFACT_BYTES,
2387            &mut aggregate,
2388        )?;
2389        scanned_snapshot_manifests.push((
2390            PathBuf::from(version).join(SNAPSHOT_MANIFEST),
2391            manifest_entry.clone(),
2392            hex_digest(Sha256::digest(&bytes)),
2393        ));
2394        let manifest =
2395            parse_snapshot_manifest(&bytes, &snapshot.display_path.join(SNAPSHOT_MANIFEST))?;
2396        if manifest.version != version {
2397            return Err(loader_error(
2398                "legacy snapshot records a different version identity",
2399            ));
2400        }
2401        snapshot.require_revision(&snapshot_before)?;
2402        let candidate_key = (
2403            manifest.source_migration.clone(),
2404            manifest.schema_hash.clone(),
2405        );
2406        let Some(expected_hashes) = expected_hashes_by_source.get(&manifest.source_migration)
2407        else {
2408            continue;
2409        };
2410        if !expected_hashes.contains(&manifest.schema_hash) {
2411            return Err(loader_error(format!(
2412                "legacy snapshot source {} has a non-equivalent schema hash claim",
2413                manifest.source_migration
2414            )));
2415        }
2416
2417        let remaining_entries = MAX_LEGACY_DIRECTORY_ENTRIES.saturating_sub(nested_entries);
2418        let capture = snapshot.capture_with_limit(remaining_entries)?;
2419        nested_entries = nested_entries.saturating_add(capture.entries.len());
2420        let captured_manifest_entry = capture
2421            .entries
2422            .iter()
2423            .find(|candidate| candidate.name == OsStr::new(SNAPSHOT_MANIFEST))
2424            .ok_or_else(|| loader_error("legacy snapshot manifest is absent"))?;
2425        require_regular(captured_manifest_entry)?;
2426        let mut revalidation_bytes = 0usize;
2427        let captured_bytes = read_bounded(
2428            &snapshot,
2429            captured_manifest_entry,
2430            MAX_LEGACY_ARTIFACT_BYTES,
2431            &mut revalidation_bytes,
2432        )?;
2433        if captured_bytes != bytes {
2434            return Err(loader_error(
2435                "legacy snapshot manifest changed before authoritative verification",
2436            ));
2437        }
2438        let captured_manifest = parse_snapshot_manifest(
2439            &captured_bytes,
2440            &snapshot.display_path.join(SNAPSHOT_MANIFEST),
2441        )?;
2442        let verified = verify_snapshot(&snapshot, &capture, &captured_manifest, &mut aggregate)?;
2443        match available.get(&candidate_key) {
2444            Some(existing) if existing.schema_typeql != verified.schema_typeql => {
2445                return Err(loader_error(format!(
2446                    "legacy snapshot source {} has non-equivalent candidates for one schema hash",
2447                    captured_manifest.source_migration
2448                )));
2449            }
2450            Some(_) => {}
2451            None => {
2452                available.insert(candidate_key, verified);
2453            }
2454        }
2455        snapshot.require_capture(&capture)?;
2456        verified_snapshot_captures.push((snapshot, capture));
2457    }
2458    #[cfg(test)]
2459    TEST_AFTER_SNAPSHOT_SCAN.with(|hook| {
2460        if let Some(hook) = hook.borrow_mut().take() {
2461            hook();
2462        }
2463    });
2464    require_scanned_snapshot_manifests(snapshots, &scanned_snapshot_manifests)?;
2465    history.require_retained_membership()?;
2466
2467    for (_, source, schema_hash) in &needed_authorities {
2468        if !available.contains_key(&(source.clone(), schema_hash.clone())) {
2469            return Err(loader_error(format!(
2470                "legacy snapshot authority {source} has no immutable snapshot with its trusted schema hash"
2471            )));
2472        }
2473    }
2474
2475    let mut verified_by_authority = BTreeMap::<(String, String, String), VerifiedLegacyHead>::new();
2476    let mut converged: Option<(String, VerifiedLegacyHead)> = None;
2477    for head in heads {
2478        let authority = history
2479            .snapshot_authorities
2480            .get(&(head.app_label.clone(), head.name.clone()))
2481            .ok_or_else(|| {
2482                loader_error(format!(
2483                    "legacy graph head {} has no resolved snapshot authority",
2484                    head.name
2485                ))
2486            })?;
2487        let authority_key = (
2488            authority.source.app_label.clone(),
2489            authority.source.migration_name.clone(),
2490            authority.schema_hash.clone(),
2491        );
2492        let verified = if let Some(verified) = verified_by_authority.get(&authority_key) {
2493            verified.clone()
2494        } else {
2495            let verified = available
2496                .get(&(
2497                    authority.source.migration_name.clone(),
2498                    authority.schema_hash.clone(),
2499                ))
2500                .ok_or_else(|| {
2501                    loader_error(format!(
2502                        "legacy graph head {} resolves to snapshot source {} but no immutable snapshot with its trusted schema hash exists",
2503                        head.name, authority.source.migration_name
2504                    ))
2505                })?
2506                .clone();
2507            verified_by_authority.insert(authority_key, verified.clone());
2508            verified
2509        };
2510
2511        match &converged {
2512            Some((schema_hash, existing))
2513                if schema_hash != &authority.schema_hash
2514                    || existing.schema_typeql != verified.schema_typeql =>
2515            {
2516                return Err(loader_error(
2517                    "legacy graph heads resolve to divergent authoritative snapshots",
2518                ));
2519            }
2520            Some(_) => {}
2521            None => converged = Some((authority.schema_hash.clone(), verified)),
2522        }
2523    }
2524
2525    for (snapshot, capture) in &verified_snapshot_captures {
2526        snapshot.require_capture(capture)?;
2527    }
2528    require_scanned_snapshot_manifests(snapshots, &scanned_snapshot_manifests)?;
2529    history.require_retained_membership()?;
2530    converged
2531        .map(|(_, verified)| verified)
2532        .ok_or_else(|| loader_error("legacy adoption history has no verified graph head"))
2533}
2534
2535fn require_scanned_snapshot_manifests(
2536    snapshots: &LegacyDirectoryAuthority,
2537    manifests: &[(PathBuf, LegacyDirectoryEntry, String)],
2538) -> Result<()> {
2539    let mut aggregate = 0usize;
2540    for (relative, entry, expected_digest) in manifests {
2541        let bytes = snapshots
2542            .read_relative_bounded(relative, MAX_LEGACY_ARTIFACT_BYTES, Some(entry))
2543            .map_err(|_| {
2544                loader_error("scanned legacy snapshot manifest changed after bounded scan")
2545            })?;
2546        aggregate = aggregate.saturating_add(bytes.len());
2547        if aggregate > MAX_LEGACY_HISTORY_BYTES
2548            || hex_digest(Sha256::digest(&bytes)) != *expected_digest
2549        {
2550            return Err(loader_error(
2551                "scanned legacy snapshot manifest changed after bounded scan",
2552            ));
2553        }
2554    }
2555    Ok(())
2556}
2557
2558fn verify_snapshot(
2559    directory: &LegacyDirectoryAuthority,
2560    capture: &LegacyDirectoryCapture,
2561    manifest: &SnapshotManifest,
2562    aggregate: &mut usize,
2563) -> Result<VerifiedLegacyHead> {
2564    if !is_lower_hex(&manifest.schema_hash, 64) {
2565        return Err(loader_error("legacy snapshot schema hash is malformed"));
2566    }
2567    if manifest.file_hashes.len() > MAX_LEGACY_DIRECTORY_ENTRIES {
2568        return Err(loader_error(
2569            "legacy snapshot file manifest exceeds the entry ceiling",
2570        ));
2571    }
2572    let expected_files = manifest
2573        .file_hashes
2574        .keys()
2575        .cloned()
2576        .collect::<BTreeSet<_>>();
2577    if !expected_files.contains(SNAPSHOT_SCHEMA) {
2578        return Err(loader_error(
2579            "legacy snapshot manifest does not bind schema.tql",
2580        ));
2581    }
2582    for (name, hash) in &manifest.file_hashes {
2583        if Path::new(name).components().count() != 1 || !is_lower_hex(hash, 64) {
2584            return Err(loader_error(
2585                "legacy snapshot manifest contains an invalid file identity",
2586            ));
2587        }
2588    }
2589
2590    let mut observed = BTreeSet::new();
2591    let mut schema = None;
2592    for entry in &capture.entries {
2593        if entry.name == OsStr::new(SNAPSHOT_MANIFEST) {
2594            require_regular(entry)?;
2595            continue;
2596        }
2597        // Released V1 validates only files named by snapshot.json. Ambient
2598        // direct children (most commonly __pycache__) are not semantic
2599        // snapshot authority: retain the directory identity, but never open,
2600        // follow, hash, or materialize those unbound entries.
2601        let Some(name) = entry.name.to_str() else {
2602            continue;
2603        };
2604        if !expected_files.contains(name) {
2605            continue;
2606        }
2607        require_regular(entry)?;
2608        let bytes = read_bounded(directory, entry, MAX_LEGACY_ARTIFACT_BYTES, aggregate)?;
2609        let actual = hex_digest(Sha256::digest(&bytes));
2610        if manifest.file_hashes.get(name) != Some(&actual) {
2611            return Err(loader_error(format!(
2612                "legacy snapshot file hash mismatch for {name}"
2613            )));
2614        }
2615        observed.insert(name.to_owned());
2616        if name == SNAPSHOT_SCHEMA {
2617            schema = Some(
2618                String::from_utf8(bytes)
2619                    .map_err(|_| loader_error("legacy snapshot schema.tql is not valid UTF-8"))?,
2620            );
2621        }
2622    }
2623    if observed != expected_files {
2624        return Err(loader_error(
2625            "legacy snapshot is missing a file bound by snapshot.json",
2626        ));
2627    }
2628    let schema_typeql =
2629        schema.ok_or_else(|| loader_error("legacy snapshot schema.tql is absent"))?;
2630    if hex_digest(Sha256::digest(schema_typeql.as_bytes())) != manifest.schema_hash {
2631        return Err(loader_error(
2632            "legacy snapshot schema hash disagrees with schema.tql",
2633        ));
2634    }
2635    directory.require_capture(capture)?;
2636    Ok(VerifiedLegacyHead {
2637        source_migration: manifest.source_migration.clone(),
2638        schema_typeql,
2639    })
2640}
2641
2642fn read_bounded(
2643    directory: &LegacyDirectoryAuthority,
2644    entry: &LegacyDirectoryEntry,
2645    limit: usize,
2646    aggregate: &mut usize,
2647) -> Result<Vec<u8>> {
2648    let mut file = crate::loader::open_regular_readonly_nofollow(&directory.directory, &entry.name)
2649        .map_err(|_| loader_error("legacy artifact cannot be opened through retained authority"))?;
2650    let before = LegacyMetadataRevision::capture(
2651        &file
2652            .metadata()
2653            .map_err(|_| loader_error("legacy artifact metadata cannot be inspected"))?,
2654    )?;
2655    if before != entry.revision {
2656        return Err(loader_error(
2657            "legacy artifact changed after bounded directory enumeration",
2658        ));
2659    }
2660    let mut bytes = Vec::new();
2661    (&mut file)
2662        .take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
2663        .read_to_end(&mut bytes)
2664        .map_err(|_| loader_error("legacy artifact failed during bounded read"))?;
2665    let after = LegacyMetadataRevision::capture(
2666        &file
2667            .metadata()
2668            .map_err(|_| loader_error("legacy artifact metadata cannot be re-inspected"))?,
2669    )?;
2670    if before != after {
2671        return Err(loader_error("legacy artifact changed during bounded read"));
2672    }
2673    if bytes.len() > limit {
2674        return Err(loader_error("legacy artifact exceeds the byte ceiling"));
2675    }
2676    *aggregate = aggregate.saturating_add(bytes.len());
2677    if *aggregate > MAX_LEGACY_HISTORY_BYTES {
2678        return Err(loader_error(
2679            "legacy history exceeds the aggregate byte ceiling",
2680        ));
2681    }
2682    Ok(bytes)
2683}
2684
2685fn require_regular(entry: &LegacyDirectoryEntry) -> Result<()> {
2686    if entry.revision.kind != LegacyEntryKind::File {
2687        return Err(loader_error(
2688            "legacy authority must be a regular file, not a link or special entry",
2689        ));
2690    }
2691    Ok(())
2692}
2693
2694fn is_migration_stem(stem: &str) -> bool {
2695    let bytes = stem.as_bytes();
2696    bytes.len() >= 5 && bytes[..4].iter().all(u8::is_ascii_digit) && bytes[4] == b'_'
2697}
2698
2699fn looks_migration_shaped_bytes(name: &OsStr) -> bool {
2700    #[cfg(unix)]
2701    {
2702        use std::os::unix::ffi::OsStrExt as _;
2703        let bytes = name.as_bytes();
2704        bytes.len() >= 8
2705            && bytes[..4].iter().all(u8::is_ascii_digit)
2706            && bytes[4] == b'_'
2707            && (bytes.ends_with(b".py")
2708                || bytes.ends_with(b".json")
2709                || bytes.ends_with(ADOPTION_SUFFIX.as_bytes()))
2710    }
2711    #[cfg(not(unix))]
2712    {
2713        let _ = name;
2714        false
2715    }
2716}
2717
2718fn is_recognized_legacy_root_name(name: &OsStr) -> bool {
2719    if name == OsStr::new(CONVERSION_JOURNAL) || name == OsStr::new("snapshots") {
2720        return true;
2721    }
2722    let Some(name) = name.to_str() else {
2723        return looks_migration_shaped_bytes(name);
2724    };
2725    if let Some(stem) = name.strip_suffix(ADOPTION_SUFFIX) {
2726        return is_migration_stem(stem);
2727    }
2728    let Some((stem, extension)) = name.rsplit_once('.') else {
2729        return false;
2730    };
2731    matches!(extension, "py" | "json") && is_migration_stem(stem)
2732}
2733
2734fn is_current_platform_direct_child(name: &OsStr) -> bool {
2735    let path = Path::new(name);
2736    let mut components = path.components();
2737    if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
2738        return false;
2739    }
2740    #[cfg(not(windows))]
2741    {
2742        !name.is_empty()
2743    }
2744    #[cfg(windows)]
2745    {
2746        let Some(portable) = name.to_str() else {
2747            return false;
2748        };
2749        let trimmed = portable.trim_end_matches(['.', ' ']);
2750        let windows_stem = portable
2751            .split('.')
2752            .next()
2753            .unwrap_or(portable)
2754            .trim_end_matches(['.', ' '])
2755            .to_ascii_uppercase();
2756        let windows_device = matches!(
2757            windows_stem.as_str(),
2758            "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
2759        ) || ["COM", "LPT"].iter().any(|prefix| {
2760            windows_stem.strip_prefix(prefix).is_some_and(|suffix| {
2761                matches!(
2762                    suffix,
2763                    "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
2764                )
2765            })
2766        });
2767        !portable.is_empty()
2768            && !portable.contains(['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'])
2769            && !portable.chars().any(char::is_control)
2770            && trimmed == portable
2771            && !windows_device
2772    }
2773}
2774
2775fn is_snapshot_version(value: &str) -> bool {
2776    value.len() == 5
2777        && value.starts_with('v')
2778        && value.as_bytes()[1..].iter().all(u8::is_ascii_digit)
2779}
2780
2781fn is_lower_hex(value: &str, length: usize) -> bool {
2782    value.len() == length
2783        && value
2784            .bytes()
2785            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2786}
2787
2788fn hash_field(hasher: &mut Sha256, value: &[u8]) {
2789    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
2790    hasher.update(value);
2791}
2792
2793fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
2794    bytes
2795        .as_ref()
2796        .iter()
2797        .map(|byte| format!("{byte:02x}"))
2798        .collect()
2799}
2800
2801fn loader_error(message: impl Into<String>) -> MigrationError {
2802    MigrationError::Loader {
2803        message: message.into(),
2804    }
2805}
2806
2807#[cfg(test)]
2808mod tests {
2809    use super::*;
2810
2811    fn dependency(name: &str) -> MigrationDependencySpec {
2812        MigrationDependencySpec {
2813            app_label: "example".to_owned(),
2814            migration_name: name.to_owned(),
2815        }
2816    }
2817
2818    fn archive(
2819        name: &str,
2820        dependencies: Vec<MigrationDependencySpec>,
2821        source_text: &str,
2822        effect: LegacySchemaEffect,
2823        schema_source: &str,
2824        schema_hash: &str,
2825    ) -> LegacyAdoptionMetadata {
2826        LegacyAdoptionMetadata::new(
2827            "example",
2828            name,
2829            dependencies,
2830            migration_file_checksum(source_text),
2831            hex_digest(Sha256::digest(source_text.as_bytes())),
2832            effect,
2833            dependency(schema_source),
2834            schema_hash,
2835        )
2836        .expect("valid archive")
2837    }
2838
2839    fn write_archive(directory: &Path, name: &str, source: &str, archive: &LegacyAdoptionMetadata) {
2840        let mut archive = archive.clone();
2841        let directory_app_label = directory
2842            .file_name()
2843            .and_then(OsStr::to_str)
2844            .expect("test directory app label");
2845        if archive.app_label == "example" && directory_app_label != "example" {
2846            archive.app_label = directory_app_label.to_owned();
2847            for dependency in &mut archive.dependencies {
2848                if dependency.app_label == "example" {
2849                    dependency.app_label = directory_app_label.to_owned();
2850                }
2851            }
2852            if archive.schema_source.app_label == "example" {
2853                archive.schema_source.app_label = directory_app_label.to_owned();
2854            }
2855            archive.metadata_digest = archive.expected_digest();
2856        }
2857        fs::write(directory.join(format!("{name}.py")), source).expect("Python source");
2858        fs::write(
2859            directory.join(format!("{name}{ADOPTION_SUFFIX}")),
2860            serde_json::to_vec(&archive).expect("archive JSON"),
2861        )
2862        .expect("archive writes");
2863    }
2864
2865    fn sidecar_spec(
2866        app_label: &str,
2867        name: &str,
2868        dependencies: Vec<MigrationDependencySpec>,
2869        source: &str,
2870        operations: Vec<OperationSpec>,
2871    ) -> MigrationSpec {
2872        MigrationSpec {
2873            app_label: app_label.to_owned(),
2874            name: name.to_owned(),
2875            dependencies,
2876            operations,
2877            checksum: Some(migration_file_checksum(source)),
2878            source_sha256: Some("0".repeat(64)),
2879            reversible: true,
2880        }
2881    }
2882
2883    fn sidecar_archive(
2884        source_name: &str,
2885        source: &str,
2886        sidecar: &MigrationSpec,
2887        sidecar_bytes: &[u8],
2888        effect: LegacySchemaEffect,
2889        schema_source: &str,
2890        schema_hash: &str,
2891    ) -> LegacySidecarAdoptionMetadata {
2892        let effective_app_label = if sidecar.app_label.is_empty() {
2893            "example"
2894        } else {
2895            &sidecar.app_label
2896        };
2897        let effective_name = if sidecar.name.is_empty() {
2898            source_name
2899        } else {
2900            &sidecar.name
2901        };
2902        LegacySidecarAdoptionMetadata::new(
2903            source_name,
2904            effective_app_label,
2905            effective_name,
2906            sidecar.dependencies.clone(),
2907            migration_file_checksum(source),
2908            sidecar.checksum.clone(),
2909            hex_digest(Sha256::digest(source.as_bytes())),
2910            hex_digest(Sha256::digest(sidecar_bytes)),
2911            effect,
2912            dependency(schema_source),
2913            schema_hash,
2914        )
2915        .expect("valid sidecar archive")
2916    }
2917
2918    fn write_sidecar_archive(
2919        directory: &Path,
2920        source_name: &str,
2921        source: &str,
2922        sidecar: &MigrationSpec,
2923        archive: &LegacySidecarAdoptionMetadata,
2924    ) {
2925        fs::write(directory.join(format!("{source_name}.py")), source)
2926            .expect("sidecar Python source");
2927        fs::write(
2928            directory.join(format!("{source_name}.json")),
2929            serde_json::to_vec(sidecar).expect("sidecar JSON"),
2930        )
2931        .expect("sidecar writes");
2932        fs::write(
2933            directory.join(format!("{source_name}{ADOPTION_SUFFIX}")),
2934            serde_json::to_vec(archive).expect("sidecar adoption JSON"),
2935        )
2936        .expect("sidecar archive writes");
2937    }
2938
2939    fn ignored(name: &str, source: &str) -> LegacyIgnoredSourceMetadata {
2940        LegacyIgnoredSourceMetadata::new(
2941            name,
2942            migration_file_checksum(source),
2943            hex_digest(Sha256::digest(source.as_bytes())),
2944        )
2945        .expect("valid ignored-source metadata")
2946    }
2947
2948    fn write_ignored(
2949        directory: &Path,
2950        name: &str,
2951        source: &str,
2952        metadata: &LegacyIgnoredSourceMetadata,
2953    ) {
2954        fs::write(directory.join(format!("{name}.py")), source).expect("ignored Python source");
2955        fs::write(
2956            directory.join(format!("{name}{ADOPTION_SUFFIX}")),
2957            serde_json::to_vec(metadata).expect("ignored-source metadata JSON"),
2958        )
2959        .expect("ignored-source metadata writes");
2960    }
2961
2962    fn write_snapshot(directory: &Path, version: &str, source: &str, schema: &str) {
2963        let snapshot = directory.join(format!("snapshots/{version}"));
2964        fs::create_dir_all(&snapshot).expect("snapshot directory");
2965        fs::write(snapshot.join(SNAPSHOT_SCHEMA), schema).expect("schema writes");
2966        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
2967        let manifest = serde_json::json!({
2968            "version": version,
2969            "source_migration": source,
2970            "schema_hash": schema_hash,
2971            "file_hashes": {"schema.tql": hex_digest(Sha256::digest(schema.as_bytes()))},
2972            "type_bridge_version": "1.5.11",
2973            "type_bridge_core_version": "1.5.11"
2974        });
2975        fs::write(
2976            snapshot.join(SNAPSHOT_MANIFEST),
2977            serde_json::to_vec(&manifest).expect("manifest JSON"),
2978        )
2979        .expect("manifest writes");
2980    }
2981
2982    fn write_snapshot_migration(
2983        directory: &Path,
2984        name: &str,
2985        dependencies: Vec<MigrationDependencySpec>,
2986        source: &str,
2987        version: &str,
2988        schema: &str,
2989    ) -> String {
2990        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
2991        let metadata = archive(
2992            name,
2993            dependencies,
2994            source,
2995            LegacySchemaEffect::Snapshot,
2996            name,
2997            &schema_hash,
2998        );
2999        write_archive(directory, name, source, &metadata);
3000        write_snapshot(directory, version, name, schema);
3001        schema_hash
3002    }
3003
3004    fn reset_captured_entry_count() {
3005        TEST_CAPTURED_DIRECTORY_ENTRIES.with(|count| count.set(0));
3006    }
3007
3008    fn captured_entry_count() -> usize {
3009        TEST_CAPTURED_DIRECTORY_ENTRIES.with(std::cell::Cell::get)
3010    }
3011
3012    #[test]
3013    fn archive_digest_binds_dependencies_effect_and_snapshot_source() {
3014        let cross_language = LegacyAdoptionMetadata::new(
3015            "example",
3016            "0001_initial",
3017            Vec::new(),
3018            "d3461e95d22dcdb8",
3019            hex_digest(Sha256::digest(b"class Migration: pass\n")),
3020            LegacySchemaEffect::Snapshot,
3021            dependency("0001_initial"),
3022            "0ec6fdcdeecccbcd6373795bb147f598b03b4f588ec28b9256b2b420e8dd36a9",
3023        )
3024        .expect("cross-language fixture");
3025        assert_eq!(
3026            cross_language.metadata_digest,
3027            "02b001d9756a151fec7e78f26cbc923ec563b7c0440b71b87a74dac099428431",
3028        );
3029
3030        let no_op = LegacyAdoptionMetadata::new(
3031            "example",
3032            "0002_empty",
3033            vec![dependency("0001_initial")],
3034            "0123456789abcdef",
3035            hex_digest(Sha256::digest(b"class Migration: pass\n")),
3036            LegacySchemaEffect::UnchangedNoop,
3037            dependency("0001_initial"),
3038            "a".repeat(64),
3039        )
3040        .expect("checksum-bound no-op archive");
3041        assert_eq!(
3042            no_op.metadata_digest,
3043            "5493f6efe6d50ba3f4072ad435e18fc7a702f20741d02dfdbcc6bee2c0415163",
3044        );
3045
3046        let source = "class Migration: pass\n";
3047        let archive = archive(
3048            "0002_backfill",
3049            vec![dependency("0001_initial")],
3050            source,
3051            LegacySchemaEffect::UnchangedRunPython,
3052            "0001_initial",
3053            &"a".repeat(64),
3054        );
3055        archive.verify().expect("digest verifies");
3056
3057        let original = archive.clone();
3058        let mut tampered = original.clone();
3059        tampered.dependencies[0].migration_name = "0009_forged".to_owned();
3060        assert!(tampered.verify().is_err());
3061
3062        let mut tampered = original.clone();
3063        tampered.schema_source.migration_name = "0009_forged".to_owned();
3064        assert!(tampered.verify().is_err());
3065
3066        let mut tampered = original.clone();
3067        tampered.schema_effect = LegacySchemaEffect::Snapshot;
3068        assert!(tampered.verify().is_err());
3069
3070        let mut tampered = original;
3071        let replacement = if tampered.metadata_digest.starts_with('0') {
3072            "1"
3073        } else {
3074            "0"
3075        };
3076        tampered.metadata_digest.replace_range(..1, replacement);
3077        assert!(tampered.verify().is_err());
3078    }
3079
3080    #[test]
3081    fn sidecar_digest_binds_both_files_and_effective_semantics() {
3082        let source = "raise RuntimeError('sidecar prevents import')\n";
3083        let sidecar = sidecar_spec(
3084            "legacy_app",
3085            "0001_effective",
3086            Vec::new(),
3087            source,
3088            Vec::new(),
3089        );
3090        let bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3091        let archive = LegacySidecarAdoptionMetadata::new(
3092            "0001_raw",
3093            "legacy_app",
3094            "0001_effective",
3095            Vec::new(),
3096            migration_file_checksum(source),
3097            sidecar.checksum.clone(),
3098            hex_digest(Sha256::digest(source.as_bytes())),
3099            hex_digest(Sha256::digest(&bytes)),
3100            LegacySchemaEffect::Snapshot,
3101            MigrationDependencySpec {
3102                app_label: "legacy_app".to_owned(),
3103                migration_name: "0001_effective".to_owned(),
3104            },
3105            "a".repeat(64),
3106        )
3107        .expect("sidecar metadata");
3108        archive.verify().expect("sidecar digest verifies");
3109
3110        let mut tampered = archive.clone();
3111        tampered.sidecar_sha256 = "b".repeat(64);
3112        assert!(tampered.verify().is_err());
3113
3114        let mut tampered = archive.clone();
3115        tampered.dependencies.push(MigrationDependencySpec {
3116            app_label: "legacy_app".to_owned(),
3117            migration_name: "0000_forged".to_owned(),
3118        });
3119        assert!(tampered.verify().is_err());
3120
3121        let mut tampered = archive;
3122        tampered.schema_effect = LegacySchemaEffect::UnchangedNoop;
3123        assert!(tampered.verify().is_err());
3124    }
3125
3126    #[test]
3127    fn sidecar_archive_digest_matches_the_python_converter_fixture() {
3128        let source = concat!(
3129            "from typing import ClassVar\n\n",
3130            "from type_bridge.migration import Migration\n",
3131            "from type_bridge.migration.operations import Operation\n",
3132            "from type_bridge.migration import operations as ops\n\n\n",
3133            "class LegacyMigration(Migration):\n",
3134            "    dependencies: ClassVar[list[tuple[str, str]]] = []\n",
3135            "    operations: ClassVar[list[Operation]] = [\n",
3136            "        ops.RunTypeQL(\n",
3137            "            forward=\"define attribute snapshot-name, value string;\",\n",
3138            "            reverse=\"undefine attribute snapshot-name;\",\n",
3139            "        ),\n",
3140            "    ]\n",
3141        );
3142        let source_sha256 = hex_digest(Sha256::digest(source.as_bytes()));
3143        let sidecar = MigrationSpec {
3144            app_label: "migrations".to_owned(),
3145            name: "0001_initial".to_owned(),
3146            dependencies: Vec::new(),
3147            operations: vec![OperationSpec::RunTypeql {
3148                forward: "define attribute snapshot-name, value string;".to_owned(),
3149                reverse: Some("undefine attribute snapshot-name;".to_owned()),
3150            }],
3151            checksum: Some(migration_file_checksum(source)),
3152            source_sha256: Some(source_sha256.clone()),
3153            reversible: true,
3154        };
3155        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar fixture JSON");
3156        let schema_hash = hex_digest(Sha256::digest(
3157            b"define\nattribute snapshot-name, value string;\n",
3158        ));
3159        let archive = LegacySidecarAdoptionMetadata::new(
3160            "0001_initial",
3161            "migrations",
3162            "0001_initial",
3163            Vec::new(),
3164            migration_file_checksum(source),
3165            sidecar.checksum.clone(),
3166            source_sha256,
3167            hex_digest(Sha256::digest(&sidecar_bytes)),
3168            LegacySchemaEffect::Snapshot,
3169            MigrationDependencySpec {
3170                app_label: "migrations".to_owned(),
3171                migration_name: "0001_initial".to_owned(),
3172            },
3173            schema_hash,
3174        )
3175        .expect("cross-language sidecar archive");
3176        assert_eq!(
3177            archive.metadata_digest,
3178            "268a1a09328bf87482f53818d93d1b613c415694138caccc7f8a5f6df1f0abe4"
3179        );
3180    }
3181
3182    #[test]
3183    fn sidecar_copy_attribute_history_loads_without_child_snapshot() {
3184        let temporary = tempfile::tempdir().expect("temporary root");
3185        let directory = temporary.path().join("example");
3186        fs::create_dir(&directory).expect("legacy directory");
3187        let schema = "define\nattribute parent-schema, value string;\n";
3188        let schema_hash = write_snapshot_migration(
3189            &directory,
3190            "0001_initial",
3191            Vec::new(),
3192            "class Migration: pass\n",
3193            "v0001",
3194            schema,
3195        );
3196        let source = "raise RuntimeError('released sidecar wins')\n";
3197        let sidecar = sidecar_spec(
3198            "example",
3199            "0002_backfill",
3200            vec![dependency("0001_initial")],
3201            source,
3202            vec![OperationSpec::CopyAttribute {
3203                owner: None,
3204                source: None,
3205                dest: None,
3206                filter: None,
3207                forward: Some("match $x isa person; insert $x has name 'x';".to_owned()),
3208                reverse: None,
3209            }],
3210        );
3211        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3212        let archive = sidecar_archive(
3213            "0002_backfill",
3214            source,
3215            &sidecar,
3216            &sidecar_bytes,
3217            LegacySchemaEffect::UnchangedCopyAttribute,
3218            "0001_initial",
3219            &schema_hash,
3220        );
3221        write_sidecar_archive(&directory, "0002_backfill", source, &sidecar, &archive);
3222
3223        let history = load_adoption_history(&directory).expect("sidecar history loads");
3224        assert_eq!(history.graph().migrations.len(), 2);
3225        assert_eq!(history.graph().migrations[1].name, "0002_backfill");
3226        assert!(history.graph().migrations[1].operations.is_empty());
3227        assert_eq!(
3228            history.graph().migrations[1].checksum.as_deref(),
3229            Some(migration_file_checksum(source).as_str())
3230        );
3231        let head = reconstruct_legacy_head(&history).expect("parent snapshot reconstructs");
3232        assert_eq!(head.schema_typeql(), schema);
3233    }
3234
3235    #[test]
3236    fn optional_checksum_sidecar_preserves_divergent_effective_identity() {
3237        let temporary = tempfile::tempdir().expect("temporary root");
3238        let directory = temporary.path().join("migrations");
3239        fs::create_dir(&directory).expect("legacy directory");
3240        let source = "raise RuntimeError('released sidecar wins')\n";
3241        let mut sidecar = sidecar_spec(
3242            "legacy_app",
3243            "0001_effective",
3244            Vec::new(),
3245            source,
3246            Vec::new(),
3247        );
3248        sidecar.checksum = None;
3249        // Deliberately stale: released Python ignores this additive field.
3250        sidecar.source_sha256 = Some("0".repeat(64));
3251        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3252        let schema = "define\nattribute effective-identity, value string;\n";
3253        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3254        let archive = LegacySidecarAdoptionMetadata::new(
3255            "0001_raw",
3256            "legacy_app",
3257            "0001_effective",
3258            Vec::new(),
3259            migration_file_checksum(source),
3260            None,
3261            hex_digest(Sha256::digest(source.as_bytes())),
3262            hex_digest(Sha256::digest(&sidecar_bytes)),
3263            LegacySchemaEffect::Snapshot,
3264            MigrationDependencySpec {
3265                app_label: "legacy_app".to_owned(),
3266                migration_name: "0001_effective".to_owned(),
3267            },
3268            &schema_hash,
3269        )
3270        .expect("sidecar archive");
3271        write_sidecar_archive(&directory, "0001_raw", source, &sidecar, &archive);
3272        write_snapshot(&directory, "v0001", "0001_effective", schema);
3273
3274        let history = load_adoption_history(&directory).expect("released sidecar loads");
3275        let migration = &history.graph().migrations[0];
3276        assert_eq!(migration.app_label, "legacy_app");
3277        assert_eq!(migration.name, "0001_effective");
3278        assert_eq!(
3279            migration.checksum.as_deref(),
3280            Some(migration_file_checksum(source).as_str())
3281        );
3282    }
3283
3284    #[test]
3285    fn mixed_copy_and_schema_sidecar_cannot_claim_copy_only_effect() {
3286        let temporary = tempfile::tempdir().expect("temporary root");
3287        let directory = temporary.path().join("example");
3288        fs::create_dir(&directory).expect("legacy directory");
3289        let schema = "define\nattribute parent-schema, value string;\n";
3290        let schema_hash = write_snapshot_migration(
3291            &directory,
3292            "0001_initial",
3293            Vec::new(),
3294            "class Migration: pass\n",
3295            "v0001",
3296            schema,
3297        );
3298        let source = "# released sidecar authority\n";
3299        let sidecar = sidecar_spec(
3300            "example",
3301            "0002_mixed",
3302            vec![dependency("0001_initial")],
3303            source,
3304            vec![
3305                OperationSpec::CopyAttribute {
3306                    owner: None,
3307                    source: None,
3308                    dest: None,
3309                    filter: None,
3310                    forward: Some("match $x isa person; insert $x has name 'x';".to_owned()),
3311                    reverse: None,
3312                },
3313                OperationSpec::RunTypeql {
3314                    forward: "define attribute schema-change, value string;".to_owned(),
3315                    reverse: None,
3316                },
3317            ],
3318        );
3319        let bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3320        let archive = sidecar_archive(
3321            "0002_mixed",
3322            source,
3323            &sidecar,
3324            &bytes,
3325            LegacySchemaEffect::UnchangedCopyAttribute,
3326            "0001_initial",
3327            &schema_hash,
3328        );
3329        write_sidecar_archive(&directory, "0002_mixed", source, &sidecar, &archive);
3330
3331        let error = load_adoption_history(&directory)
3332            .expect_err("mixed sidecar cannot inherit a snapshot")
3333            .to_string();
3334        assert!(error.contains("operations contradict"), "{error}");
3335    }
3336
3337    #[test]
3338    fn retained_sidecar_tamper_is_rejected_by_exact_digest() {
3339        let temporary = tempfile::tempdir().expect("temporary root");
3340        let directory = temporary.path().join("example");
3341        fs::create_dir(&directory).expect("legacy directory");
3342        let source = "raise RuntimeError('not imported')\n";
3343        let mut sidecar = sidecar_spec("example", "0001_initial", Vec::new(), source, Vec::new());
3344        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3345        let schema = "define\nattribute exact-sidecar, value string;\n";
3346        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3347        let archive = sidecar_archive(
3348            "0001_initial",
3349            source,
3350            &sidecar,
3351            &sidecar_bytes,
3352            LegacySchemaEffect::Snapshot,
3353            "0001_initial",
3354            &schema_hash,
3355        );
3356        write_sidecar_archive(&directory, "0001_initial", source, &sidecar, &archive);
3357        write_snapshot(&directory, "v0001", "0001_initial", schema);
3358        sidecar.reversible = false;
3359        fs::write(
3360            directory.join("0001_initial.json"),
3361            serde_json::to_vec(&sidecar).expect("tampered sidecar JSON"),
3362        )
3363        .expect("sidecar tampers");
3364
3365        let error = load_adoption_history(&directory)
3366            .expect_err("exact sidecar tamper must reject")
3367            .to_string();
3368        assert!(error.contains("exact JSON digest differs"), "{error}");
3369    }
3370
3371    #[test]
3372    fn empty_sidecar_app_label_cannot_forge_fallback_owner() {
3373        let temporary = tempfile::tempdir().expect("temporary root");
3374        let directory = temporary.path().join("migrations");
3375        fs::create_dir(&directory).expect("legacy directory");
3376        let source = "raise RuntimeError('not imported')\n";
3377        let sidecar = sidecar_spec("", "0001_initial", Vec::new(), source, Vec::new());
3378        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3379        let schema = "define\nattribute owner-check, value string;\n";
3380        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3381        let archive = LegacySidecarAdoptionMetadata::new(
3382            "0001_initial",
3383            "forged_owner",
3384            "0001_initial",
3385            Vec::new(),
3386            migration_file_checksum(source),
3387            sidecar.checksum.clone(),
3388            hex_digest(Sha256::digest(source.as_bytes())),
3389            hex_digest(Sha256::digest(&sidecar_bytes)),
3390            LegacySchemaEffect::Snapshot,
3391            MigrationDependencySpec {
3392                app_label: "forged_owner".to_owned(),
3393                migration_name: "0001_initial".to_owned(),
3394            },
3395            &schema_hash,
3396        )
3397        .expect("forged archive is internally self-consistent");
3398        write_sidecar_archive(&directory, "0001_initial", source, &sidecar, &archive);
3399        write_snapshot(&directory, "v0001", "0001_initial", schema);
3400
3401        let error = load_adoption_history(&directory)
3402            .expect_err("directory fallback must reject forged owner")
3403            .to_string();
3404        assert!(
3405            error.contains("semantics differ from their adoption metadata"),
3406            "{error}"
3407        );
3408    }
3409
3410    #[test]
3411    fn empty_sidecar_identity_uses_directory_and_source_fallbacks() {
3412        let temporary = tempfile::tempdir().expect("temporary root");
3413        let directory = temporary.path().join("migrations");
3414        fs::create_dir(&directory).expect("legacy directory");
3415        let source = "raise RuntimeError('not imported')\n";
3416        let sidecar = sidecar_spec("", "", Vec::new(), source, Vec::new());
3417        let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3418        let schema = "define\nattribute fallback-owner, value string;\n";
3419        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3420        let archive = LegacySidecarAdoptionMetadata::new(
3421            "0001_fallback",
3422            "migrations",
3423            "0001_fallback",
3424            Vec::new(),
3425            migration_file_checksum(source),
3426            sidecar.checksum.clone(),
3427            hex_digest(Sha256::digest(source.as_bytes())),
3428            hex_digest(Sha256::digest(&sidecar_bytes)),
3429            LegacySchemaEffect::Snapshot,
3430            MigrationDependencySpec {
3431                app_label: "migrations".to_owned(),
3432                migration_name: "0001_fallback".to_owned(),
3433            },
3434            &schema_hash,
3435        )
3436        .expect("fallback archive");
3437        write_sidecar_archive(&directory, "0001_fallback", source, &sidecar, &archive);
3438        write_snapshot(&directory, "v0001", "0001_fallback", schema);
3439
3440        let history = load_adoption_history(&directory).expect("fallback sidecar loads");
3441        assert_eq!(history.graph().migrations[0].app_label, "migrations");
3442        assert_eq!(history.graph().migrations[0].name, "0001_fallback");
3443        assert_eq!(
3444            reconstruct_legacy_head(&history)
3445                .expect("fallback head reconstructs")
3446                .schema_typeql(),
3447            schema
3448        );
3449    }
3450
3451    #[test]
3452    fn source_archive_rejects_a_later_execution_sidecar() {
3453        let temporary = tempfile::tempdir().expect("temporary root");
3454        let directory = temporary.path().join("example");
3455        fs::create_dir(&directory).expect("legacy directory");
3456        let source = "class Migration: pass\n";
3457        write_snapshot_migration(
3458            &directory,
3459            "0001_initial",
3460            Vec::new(),
3461            source,
3462            "v0001",
3463            "define\nattribute source-authority, value string;\n",
3464        );
3465        let sidecar = sidecar_spec("example", "0001_initial", Vec::new(), source, Vec::new());
3466        fs::write(
3467            directory.join("0001_initial.json"),
3468            serde_json::to_vec(&sidecar).expect("sidecar JSON"),
3469        )
3470        .expect("late sidecar writes");
3471
3472        let error = load_adoption_history(&directory)
3473            .expect_err("released sidecar precedence must invalidate a source archive")
3474            .to_string();
3475        assert!(
3476            error.contains("source-authoritative adoption metadata"),
3477            "{error}"
3478        );
3479        assert!(
3480            error.contains("conflicts with a retained JSON sidecar"),
3481            "{error}"
3482        );
3483    }
3484
3485    #[test]
3486    fn ignored_archive_rejects_a_later_execution_sidecar() {
3487        let temporary = tempfile::tempdir().expect("temporary root");
3488        let directory = temporary.path().join("example");
3489        fs::create_dir(&directory).expect("legacy directory");
3490        let source = "# no public Migration subclass\n";
3491        write_ignored(
3492            &directory,
3493            "0001_notes",
3494            source,
3495            &ignored("0001_notes", source),
3496        );
3497        let sidecar = sidecar_spec("example", "0001_notes", Vec::new(), source, Vec::new());
3498        fs::write(
3499            directory.join("0001_notes.json"),
3500            serde_json::to_vec(&sidecar).expect("sidecar JSON"),
3501        )
3502        .expect("late sidecar writes");
3503
3504        let error = load_adoption_history(&directory)
3505            .expect_err("released sidecar precedence must invalidate ignored evidence")
3506            .to_string();
3507        assert!(
3508            error.contains("ignored-source adoption metadata"),
3509            "{error}"
3510        );
3511        assert!(
3512            error.contains("conflicts with a retained JSON sidecar"),
3513            "{error}"
3514        );
3515    }
3516
3517    #[test]
3518    fn source_archive_owner_must_match_the_migration_directory() {
3519        let temporary = tempfile::tempdir().expect("temporary root");
3520        let directory = temporary.path().join("migrations");
3521        fs::create_dir(&directory).expect("legacy directory");
3522        let source = "class Migration: pass\n";
3523        let schema = "define\nattribute source-owner, value string;\n";
3524        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3525        let archive = LegacyAdoptionMetadata::new(
3526            "forged_owner",
3527            "0001_initial",
3528            Vec::new(),
3529            migration_file_checksum(source),
3530            hex_digest(Sha256::digest(source.as_bytes())),
3531            LegacySchemaEffect::Snapshot,
3532            MigrationDependencySpec {
3533                app_label: "forged_owner".to_owned(),
3534                migration_name: "0001_initial".to_owned(),
3535            },
3536            &schema_hash,
3537        )
3538        .expect("internally bound forged archive");
3539        write_archive(&directory, "0001_initial", source, &archive);
3540        write_snapshot(&directory, "v0001", "0001_initial", schema);
3541
3542        let error = load_adoption_history(&directory)
3543            .expect_err("released source imports always use the directory app label")
3544            .to_string();
3545        assert!(
3546            error.contains("does not match migration directory app label"),
3547            "{error}"
3548        );
3549    }
3550
3551    #[test]
3552    fn orphan_execution_sidecars_remain_ignored_like_released_discovery() {
3553        let temporary = tempfile::tempdir().expect("temporary root");
3554        let directory = temporary.path().join("example");
3555        fs::create_dir(&directory).expect("legacy directory");
3556        let schema = "define\nattribute orphan-compatible, value string;\n";
3557        write_snapshot_migration(
3558            &directory,
3559            "0001_initial",
3560            Vec::new(),
3561            "class Migration: pass\n",
3562            "v0001",
3563            schema,
3564        );
3565        fs::write(
3566            directory.join("0009_deleted.json"),
3567            b"{ stale orphan sidecar",
3568        )
3569        .expect("orphan sidecar writes");
3570
3571        let history = load_adoption_history(&directory).expect("orphan sidecar is ignored");
3572        assert_eq!(history.graph().migrations.len(), 1);
3573        let head = reconstruct_legacy_head(&history).expect("snapshot reconstructs");
3574        assert_eq!(head.schema_typeql(), schema);
3575    }
3576
3577    #[test]
3578    fn snapshot_manifest_preserves_released_python_json_semantics() {
3579        let directory = tempfile::tempdir().expect("legacy directory");
3580        let schema = "define\nattribute python-json, value string;\n";
3581        write_snapshot_migration(
3582            directory.path(),
3583            "0001_initial",
3584            Vec::new(),
3585            "class Initial: pass\n",
3586            "v0001",
3587            schema,
3588        );
3589        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3590        let huge_integer = "9".repeat(400);
3591        let deep_ignored = format!("{}0{}", "[".repeat(512), "]".repeat(512));
3592        let manifest = format!(
3593            concat!(
3594                "{{\"version\":\"v0001\",",
3595                "\"source_migration\":false,",
3596                "\"source_migration\":\"0001_initial\",",
3597                "\"schema_hash\":\"{schema_hash}\",",
3598                "\"file_hashes\":{{\"schema.tql\":\"{forged}\",",
3599                "\"schema.tql\":\"{schema_hash}\"}},",
3600                "\"type_bridge_version\":NaN,",
3601                "\"type_bridge_core_version\":Infinity,",
3602                "\"ignored_negative\":-Infinity,",
3603                "\"ignored_huge\":{huge_integer},",
3604                "\"ignored_deep\":{deep_ignored},",
3605                "\"\\ud800leading-surrogate-key\":0,",
3606                "\"trailing-surrogate-key\\udc00\":0,",
3607                "\"ignored_nested\":{{\"\\ud800nested-surrogate-key\":0}},",
3608                "\"ignored_string\":\"NaN Infinity -Infinity\"}}"
3609            ),
3610            forged = "0".repeat(64),
3611            schema_hash = schema_hash,
3612            huge_integer = huge_integer,
3613            deep_ignored = deep_ignored,
3614        );
3615        fs::write(
3616            directory.path().join("snapshots/v0001/snapshot.json"),
3617            manifest,
3618        )
3619        .expect("Python-compatible manifest writes");
3620
3621        let history = load_adoption_history(directory.path()).expect(
3622            "Python json constants, big integers, deep ignored members, and duplicate keys remain accepted",
3623        );
3624        let head = reconstruct_legacy_head(&history).expect("last duplicate values authorize");
3625        assert_eq!(head.source_migration(), "0001_initial");
3626        assert_eq!(head.schema_typeql(), schema);
3627    }
3628
3629    #[test]
3630    fn snapshot_manifest_root_key_normalization_is_narrow() {
3631        let mut manifest = br#"{
3632            "\ud800leading": 0,
3633            "trailing\udc00": 0,
3634            "paired\ud83d\ude00": 0,
3635            "escaped-backslash\\ud800": 0,
3636            "nested": {"\ud800nested": 0}
3637        }"#
3638        .to_vec();
3639        sanitize_python_json_root_key_surrogates(&mut manifest);
3640
3641        assert_eq!(
3642            manifest,
3643            br#"{
3644            "\ufffdleading": 0,
3645            "trailing\ufffd": 0,
3646            "paired\ud83d\ude00": 0,
3647            "escaped-backslash\\ud800": 0,
3648            "nested": {"\ud800nested": 0}
3649        }"#
3650        );
3651    }
3652
3653    #[test]
3654    fn snapshot_manifest_rejects_lone_surrogates_in_authoritative_values() {
3655        let schema_hash = "0".repeat(64);
3656        let valid_fields = [
3657            ("version", "\"v0001\""),
3658            ("source_migration", "\"0001_initial\""),
3659            ("schema_hash", &format!("\"{schema_hash}\"")),
3660        ];
3661        for (malformed_field, malformed_value) in [
3662            ("version", "\"\\ud800v0001\""),
3663            ("source_migration", "\"0001_initial\\udc00\""),
3664            ("schema_hash", "\"\\ud800\""),
3665        ] {
3666            let fields = valid_fields
3667                .iter()
3668                .map(|(name, value)| {
3669                    let value = if *name == malformed_field {
3670                        malformed_value
3671                    } else {
3672                        value
3673                    };
3674                    format!("\"{name}\":{value}")
3675                })
3676                .chain(std::iter::once("\"file_hashes\":{}".to_owned()))
3677                .collect::<Vec<_>>()
3678                .join(",");
3679            let error = parse_snapshot_manifest(
3680                format!("{{{fields}}}").as_bytes(),
3681                Path::new("snapshot.json"),
3682            )
3683            .err()
3684            .expect("lone surrogate in an authoritative string remains invalid")
3685            .to_string();
3686            assert!(error.contains(malformed_field), "{error}");
3687        }
3688    }
3689
3690    #[test]
3691    fn snapshot_manifest_version_metadata_remains_optional() {
3692        let directory = tempfile::tempdir().expect("legacy directory");
3693        let schema = "define\nattribute optional-version, value string;\n";
3694        write_snapshot_migration(
3695            directory.path(),
3696            "0001_initial",
3697            Vec::new(),
3698            "class Initial: pass\n",
3699            "v0001",
3700            schema,
3701        );
3702        let manifest_path = directory.path().join("snapshots/v0001/snapshot.json");
3703        let mut manifest: serde_json::Value =
3704            serde_json::from_slice(&fs::read(&manifest_path).expect("manifest reads"))
3705                .expect("manifest parses");
3706        let object = manifest.as_object_mut().expect("manifest object");
3707        object.remove("type_bridge_version");
3708        object.remove("type_bridge_core_version");
3709        fs::write(
3710            &manifest_path,
3711            serde_json::to_vec(&manifest).expect("manifest serializes"),
3712        )
3713        .expect("manifest without informational versions writes");
3714
3715        let history = load_adoption_history(directory.path())
3716            .expect("released snapshots did not require informational versions");
3717        assert_eq!(
3718            reconstruct_legacy_head(&history)
3719                .expect("snapshot reconstructs")
3720                .schema_typeql(),
3721            schema
3722        );
3723    }
3724
3725    #[test]
3726    fn ignored_source_digest_binds_name_and_python_checksum() {
3727        let source = "\"\"\"Historical migration notes.\"\"\"\n";
3728        let metadata = ignored("0000_notes", source);
3729        metadata.verify().expect("ignored-source digest verifies");
3730        assert_eq!(metadata.checksum, "e14f63ac2d07fa3b");
3731        assert_eq!(
3732            metadata.metadata_digest,
3733            "0c03755f3652a25c5d52bff5367ff8fa70edc23cb5f25c76c953dbd4993b60f2",
3734        );
3735
3736        let original = metadata.clone();
3737        let mut tampered = original.clone();
3738        tampered.name = "0009_forged".to_owned();
3739        assert!(tampered.verify().is_err());
3740
3741        let mut tampered = original.clone();
3742        tampered.checksum = "0123456789abcdef".to_owned();
3743        assert!(tampered.verify().is_err());
3744
3745        let mut tampered = original;
3746        let replacement = if tampered.metadata_digest.starts_with('0') {
3747            "1"
3748        } else {
3749            "0"
3750        };
3751        tampered.metadata_digest.replace_range(..1, replacement);
3752        assert!(tampered.verify().is_err());
3753    }
3754
3755    #[test]
3756    fn ignored_sources_are_verified_and_excluded_from_the_legacy_graph() {
3757        let directory = tempfile::tempdir().expect("legacy directory");
3758        let schema = "define\nattribute name, value string;\n";
3759        write_snapshot_migration(
3760            directory.path(),
3761            "0001_initial",
3762            Vec::new(),
3763            "class Initial: pass\n",
3764            "v0001",
3765            schema,
3766        );
3767        let notes = "\"\"\"Notes retained beside migration history.\"\"\"\n";
3768        write_ignored(
3769            directory.path(),
3770            "0000_notes",
3771            notes,
3772            &ignored("0000_notes", notes),
3773        );
3774        let disabled = "class _DisabledMigration: pass\n";
3775        write_ignored(
3776            directory.path(),
3777            "0002_disabled",
3778            disabled,
3779            &ignored("0002_disabled", disabled),
3780        );
3781
3782        let history = load_adoption_history(directory.path()).expect("ignored sources verify");
3783
3784        assert_eq!(history.graph().migrations.len(), 1);
3785        assert_eq!(history.graph().migrations[0].name, "0001_initial");
3786        let head = reconstruct_legacy_head(&history).expect("normal head reconstructs");
3787        assert_eq!(head.schema_typeql(), schema);
3788    }
3789
3790    #[test]
3791    fn reconstruction_continues_the_history_wide_byte_budget() {
3792        let directory = tempfile::tempdir().expect("legacy directory");
3793        let schema = "define\nattribute budgeted, value string;\n";
3794        write_snapshot_migration(
3795            directory.path(),
3796            "0001_initial",
3797            Vec::new(),
3798            "class Initial: pass\n",
3799            "v0001",
3800            schema,
3801        );
3802        let mut history = load_adoption_history(directory.path()).expect("history loads");
3803        history.consumed_bytes = MAX_LEGACY_HISTORY_BYTES;
3804
3805        let error = reconstruct_legacy_head(&history)
3806            .expect_err("snapshot reads must continue the source/archive budget");
3807
3808        assert!(error.to_string().contains("aggregate byte ceiling"));
3809    }
3810
3811    #[test]
3812    fn ignored_source_tampering_and_forged_duplicate_records_fail_closed() {
3813        let directory = tempfile::tempdir().expect("legacy directory");
3814        let notes = "# release notes\n";
3815        write_ignored(
3816            directory.path(),
3817            "0000_notes",
3818            notes,
3819            &ignored("0000_notes", notes),
3820        );
3821        fs::write(directory.path().join("0000_notes.py"), "# tampered notes\n")
3822            .expect("ignored source tampers");
3823        let error = load_adoption_history(directory.path())
3824            .expect_err("source drift cannot retain ignored classification");
3825        assert!(
3826            error
3827                .to_string()
3828                .contains("ignored-source adoption metadata drift")
3829        );
3830
3831        let duplicate_directory = tempfile::tempdir().expect("duplicate legacy directory");
3832        let duplicated = ignored("0000_notes", notes);
3833        write_ignored(duplicate_directory.path(), "0000_notes", notes, &duplicated);
3834        write_ignored(
3835            duplicate_directory.path(),
3836            "0003_forged",
3837            notes,
3838            &duplicated,
3839        );
3840        let error = load_adoption_history(duplicate_directory.path())
3841            .expect_err("one signed identity cannot be duplicated under a forged filename");
3842        assert!(error.to_string().contains("does not match filename stem"));
3843    }
3844
3845    #[test]
3846    fn duplicate_fields_in_ignored_source_metadata_are_rejected() {
3847        let directory = tempfile::tempdir().expect("legacy directory");
3848        let source = "# notes\n";
3849        let metadata = ignored("0000_notes", source);
3850        fs::write(directory.path().join("0000_notes.py"), source).expect("Python source");
3851        fs::write(
3852            directory.path().join("0000_notes.adoption.json"),
3853            format!(
3854                "{{\"format\":\"{}\",\"name\":\"0000_notes\",\"name\":\"0000_forged\",\"checksum\":\"{}\",\"metadata_digest\":\"{}\"}}",
3855                LEGACY_IGNORED_SOURCE_METADATA_V1, metadata.checksum, metadata.metadata_digest,
3856            ),
3857        )
3858        .expect("duplicate metadata writes");
3859
3860        let error = load_adoption_history(directory.path())
3861            .expect_err("duplicate identity fields must never be last-value-wins");
3862        assert!(error.to_string().contains("duplicate field"));
3863    }
3864
3865    #[test]
3866    fn ignored_source_metadata_uses_the_legacy_artifact_byte_ceiling() {
3867        let directory = tempfile::tempdir().expect("legacy directory");
3868        fs::write(directory.path().join("0000_notes.py"), "# notes\n")
3869            .expect("ignored Python source");
3870        fs::write(
3871            directory.path().join("0000_notes.adoption.json"),
3872            vec![b' '; MAX_LEGACY_ARTIFACT_BYTES + 1],
3873        )
3874        .expect("oversized ignored-source metadata writes");
3875
3876        let error = load_adoption_history(directory.path())
3877            .expect_err("ignored-source metadata must use the common bounded reader");
3878        assert!(error.to_string().contains("exceeds the byte ceiling"));
3879    }
3880
3881    #[test]
3882    fn root_noop_requires_an_explicit_snapshot_authority() {
3883        let no_snapshot = LegacyAdoptionMetadata::new(
3884            "example",
3885            "0001_empty",
3886            Vec::new(),
3887            "0123456789abcdef",
3888            hex_digest(Sha256::digest(b"class Migration: pass\n")),
3889            LegacySchemaEffect::UnchangedNoop,
3890            dependency("0001_empty"),
3891            "a".repeat(64),
3892        )
3893        .expect_err("an authority-less root no-op must fail closed");
3894        assert!(
3895            no_snapshot
3896                .to_string()
3897                .contains("snapshot-bound dependency")
3898        );
3899
3900        let directory = tempfile::tempdir().expect("legacy directory");
3901        write_snapshot_migration(
3902            directory.path(),
3903            "0001_baseline",
3904            Vec::new(),
3905            "class Migration:\n    operations = []\n",
3906            "v0001",
3907            "define\n",
3908        );
3909        let history = load_adoption_history(directory.path()).expect("baseline history loads");
3910        let head = reconstruct_legacy_head(&history).expect("empty baseline snapshot reconstructs");
3911        assert_eq!(head.source_migration(), "0001_baseline");
3912        assert_eq!(head.schema_typeql(), "define\n");
3913        let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
3914            type_bridge_contract::schema::DocumentId::new("legacy-empty-baseline.typeql")
3915                .expect("document identity"),
3916            head.schema_typeql(),
3917        )
3918        .expect("the reconstructed empty baseline must pass the production adoption parser");
3919        assert_eq!(authority.declared().facts().len(), 0);
3920    }
3921
3922    #[test]
3923    fn run_python_head_inherits_and_reconstructs_its_parent_snapshot() {
3924        let directory = tempfile::tempdir().expect("legacy directory");
3925        let initial_source = "class Migration:\n    operations = [RunTypeQL()]\n";
3926        let backfill_source = "class Migration:\n    operations = [RunPython()]\n";
3927        let schema = "define\nattribute tag, value string;\nentity person, owns tag[] @distinct;\n";
3928        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3929
3930        let initial = archive(
3931            "0001_initial",
3932            Vec::new(),
3933            initial_source,
3934            LegacySchemaEffect::Snapshot,
3935            "0001_initial",
3936            &schema_hash,
3937        );
3938        write_archive(directory.path(), "0001_initial", initial_source, &initial);
3939        let backfill = archive(
3940            "0002_backfill",
3941            vec![dependency("0001_initial")],
3942            backfill_source,
3943            LegacySchemaEffect::UnchangedRunPython,
3944            "0001_initial",
3945            &schema_hash,
3946        );
3947        write_archive(
3948            directory.path(),
3949            "0002_backfill",
3950            backfill_source,
3951            &backfill,
3952        );
3953        write_snapshot(directory.path(), "v0001", "0001_initial", schema);
3954
3955        let history = load_adoption_history(directory.path()).expect("archival history loads");
3956        let head = reconstruct_legacy_head(&history).expect("snapshot reconstructs head");
3957        assert_eq!(head.source_migration(), "0001_initial");
3958        assert_eq!(head.schema_typeql(), schema);
3959        assert!(history.graph().migrations[0].operations.is_empty());
3960    }
3961
3962    #[test]
3963    fn noop_head_inherits_and_reconstructs_its_parent_snapshot() {
3964        let directory = tempfile::tempdir().expect("legacy directory");
3965        let initial_source = "class Migration:\n    operations = [RunTypeQL()]\n";
3966        let empty_source = "class Migration:\n    operations = []\n";
3967        let schema = "define\nattribute tag, value string;\nentity person, owns tag[] @distinct;\n";
3968        let schema_hash = write_snapshot_migration(
3969            directory.path(),
3970            "0001_initial",
3971            Vec::new(),
3972            initial_source,
3973            "v0001",
3974            schema,
3975        );
3976        let empty = archive(
3977            "0002_empty",
3978            vec![dependency("0001_initial")],
3979            empty_source,
3980            LegacySchemaEffect::UnchangedNoop,
3981            "0001_initial",
3982            &schema_hash,
3983        );
3984        write_archive(directory.path(), "0002_empty", empty_source, &empty);
3985
3986        let history = load_adoption_history(directory.path()).expect("no-op history loads");
3987        let head = reconstruct_legacy_head(&history).expect("parent snapshot reconstructs head");
3988        assert_eq!(head.source_migration(), "0001_initial");
3989        assert_eq!(head.schema_typeql(), schema);
3990        assert_eq!(history.graph().migrations.len(), 2);
3991        assert!(history.graph().migrations[1].operations.is_empty());
3992    }
3993
3994    #[test]
3995    fn noop_merge_inherits_one_converged_parent_authority() {
3996        let directory = tempfile::tempdir().expect("legacy directory");
3997        let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
3998        let schema_hash = write_snapshot_migration(
3999            directory.path(),
4000            "0001_initial",
4001            Vec::new(),
4002            "class Initial: pass\n",
4003            "v0001",
4004            schema,
4005        );
4006        for (name, dependencies) in [
4007            ("0002_left", vec![dependency("0001_initial")]),
4008            ("0003_right", vec![dependency("0001_initial")]),
4009            (
4010                "0004_merge",
4011                vec![dependency("0002_left"), dependency("0003_right")],
4012            ),
4013        ] {
4014            let source = format!("class {name}:\n    operations = []\n");
4015            let metadata = archive(
4016                name,
4017                dependencies,
4018                &source,
4019                LegacySchemaEffect::UnchangedNoop,
4020                "0001_initial",
4021                &schema_hash,
4022            );
4023            write_archive(directory.path(), name, &source, &metadata);
4024        }
4025
4026        let history = load_adoption_history(directory.path()).expect("converged no-op merge loads");
4027        let heads = history.heads().expect("validated heads");
4028        assert_eq!(heads.len(), 1);
4029        assert_eq!(heads[0].name, "0004_merge");
4030        let reconstructed =
4031            reconstruct_legacy_head(&history).expect("one parent authority reconstructs");
4032        assert_eq!(reconstructed.schema_typeql(), schema);
4033    }
4034
4035    #[test]
4036    fn noop_merge_accepts_distinct_parent_owners_with_the_same_schema() {
4037        let directory = tempfile::tempdir().expect("legacy directory");
4038        let schema = "define\nattribute shared, value string;\n";
4039        let schema_hash = write_snapshot_migration(
4040            directory.path(),
4041            "0001_left",
4042            Vec::new(),
4043            "class Left: pass\n",
4044            "v0001",
4045            schema,
4046        );
4047        write_snapshot_migration(
4048            directory.path(),
4049            "0002_right",
4050            Vec::new(),
4051            "class Right: pass\n",
4052            "v0002",
4053            schema,
4054        );
4055        let merge_source = "class Merge:\n    operations = []\n";
4056        let merge = archive(
4057            "0003_merge",
4058            vec![dependency("0001_left"), dependency("0002_right")],
4059            merge_source,
4060            LegacySchemaEffect::UnchangedNoop,
4061            "0001_left",
4062            &schema_hash,
4063        );
4064        write_archive(directory.path(), "0003_merge", merge_source, &merge);
4065
4066        let history = load_adoption_history(directory.path())
4067            .expect("equal schemas with distinct snapshot owners converge");
4068        let head = reconstruct_legacy_head(&history)
4069            .expect("all distinct snapshot owners verify before convergence");
4070        assert_eq!(head.source_migration(), "0001_left");
4071        assert_eq!(head.schema_typeql(), schema);
4072    }
4073
4074    #[test]
4075    fn convergent_multi_head_history_reconstructs_one_authoritative_schema() {
4076        let directory = tempfile::tempdir().expect("legacy directory");
4077        let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
4078        write_snapshot_migration(
4079            directory.path(),
4080            "0001_initial",
4081            Vec::new(),
4082            "class Initial: pass\n",
4083            "v0001",
4084            schema,
4085        );
4086        write_snapshot_migration(
4087            directory.path(),
4088            "0002_left",
4089            vec![dependency("0001_initial")],
4090            "class Left: pass\n",
4091            "v0002",
4092            schema,
4093        );
4094        write_snapshot_migration(
4095            directory.path(),
4096            "0003_right",
4097            vec![dependency("0001_initial")],
4098            "class Right: pass\n",
4099            "v0003",
4100            schema,
4101        );
4102
4103        let history = load_adoption_history(directory.path()).expect("multi-head history loads");
4104        assert_eq!(history.heads().expect("validated heads").len(), 2);
4105        let reconstructed =
4106            reconstruct_legacy_head(&history).expect("equal head snapshots converge");
4107        assert_eq!(reconstructed.source_migration(), "0002_left");
4108        assert_eq!(reconstructed.schema_typeql(), schema);
4109    }
4110
4111    #[test]
4112    fn divergent_multi_head_history_fails_closed() {
4113        let directory = tempfile::tempdir().expect("legacy directory");
4114        let initial_schema = "define\nattribute name, value string;\n";
4115        write_snapshot_migration(
4116            directory.path(),
4117            "0001_initial",
4118            Vec::new(),
4119            "class Initial: pass\n",
4120            "v0001",
4121            initial_schema,
4122        );
4123        write_snapshot_migration(
4124            directory.path(),
4125            "0002_left",
4126            vec![dependency("0001_initial")],
4127            "class Left: pass\n",
4128            "v0002",
4129            "define\nattribute name, value string;\nentity person, owns name;\n",
4130        );
4131        write_snapshot_migration(
4132            directory.path(),
4133            "0003_right",
4134            vec![dependency("0001_initial")],
4135            "class Right: pass\n",
4136            "v0003",
4137            "define\nattribute name, value string;\nentity company, owns name;\n",
4138        );
4139
4140        let history =
4141            load_adoption_history(directory.path()).expect("valid branched history loads");
4142        let error = reconstruct_legacy_head(&history)
4143            .expect_err("different authoritative head snapshots cannot be adopted");
4144        assert!(
4145            error
4146                .to_string()
4147                .contains("divergent authoritative snapshots")
4148        );
4149    }
4150
4151    #[test]
4152    fn run_python_inherited_head_converges_with_an_exact_sibling_head() {
4153        let directory = tempfile::tempdir().expect("legacy directory");
4154        let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
4155        let schema_hash = write_snapshot_migration(
4156            directory.path(),
4157            "0001_initial",
4158            Vec::new(),
4159            "class Initial: pass\n",
4160            "v0001",
4161            schema,
4162        );
4163        write_snapshot_migration(
4164            directory.path(),
4165            "0002_exact",
4166            vec![dependency("0001_initial")],
4167            "class Exact: pass\n",
4168            "v0002",
4169            schema,
4170        );
4171        let backfill_source = "class Backfill:\n    operations = [RunPython()]\n";
4172        let backfill = archive(
4173            "0003_backfill",
4174            vec![dependency("0001_initial")],
4175            backfill_source,
4176            LegacySchemaEffect::UnchangedRunPython,
4177            "0001_initial",
4178            &schema_hash,
4179        );
4180        write_archive(
4181            directory.path(),
4182            "0003_backfill",
4183            backfill_source,
4184            &backfill,
4185        );
4186
4187        let history = load_adoption_history(directory.path()).expect("inherited history loads");
4188        assert_eq!(history.heads().expect("validated heads").len(), 2);
4189        let reconstructed =
4190            reconstruct_legacy_head(&history).expect("inherited and exact authorities converge");
4191        assert_eq!(reconstructed.schema_typeql(), schema);
4192    }
4193
4194    #[test]
4195    fn unchanged_merge_rejects_divergent_parent_snapshots() {
4196        let directory = tempfile::tempdir().expect("legacy directory");
4197        let left_source = "class Left: pass\n";
4198        let right_source = "class Right: pass\n";
4199        let merge_source = "class Merge: pass\n";
4200        let left_hash = "a".repeat(64);
4201        let right_hash = "b".repeat(64);
4202        write_archive(
4203            directory.path(),
4204            "0001_left",
4205            left_source,
4206            &archive(
4207                "0001_left",
4208                Vec::new(),
4209                left_source,
4210                LegacySchemaEffect::Snapshot,
4211                "0001_left",
4212                &left_hash,
4213            ),
4214        );
4215        write_archive(
4216            directory.path(),
4217            "0002_right",
4218            right_source,
4219            &archive(
4220                "0002_right",
4221                Vec::new(),
4222                right_source,
4223                LegacySchemaEffect::Snapshot,
4224                "0002_right",
4225                &right_hash,
4226            ),
4227        );
4228        write_archive(
4229            directory.path(),
4230            "0003_merge",
4231            merge_source,
4232            &archive(
4233                "0003_merge",
4234                vec![dependency("0001_left"), dependency("0002_right")],
4235                merge_source,
4236                LegacySchemaEffect::UnchangedRunPython,
4237                "0001_left",
4238                &left_hash,
4239            ),
4240        );
4241
4242        let error = load_adoption_history(directory.path())
4243            .expect_err("a divergent schema-neutral merge has no one head authority");
4244        assert!(error.to_string().contains("divergent snapshot authorities"));
4245    }
4246
4247    #[test]
4248    fn noop_merge_rejects_divergent_parent_snapshots() {
4249        let directory = tempfile::tempdir().expect("legacy directory");
4250        let left_source = "class Left: pass\n";
4251        let right_source = "class Right: pass\n";
4252        let merge_source = "class Merge:\n    operations = []\n";
4253        let left_hash = "a".repeat(64);
4254        let right_hash = "b".repeat(64);
4255        write_archive(
4256            directory.path(),
4257            "0001_left",
4258            left_source,
4259            &archive(
4260                "0001_left",
4261                Vec::new(),
4262                left_source,
4263                LegacySchemaEffect::Snapshot,
4264                "0001_left",
4265                &left_hash,
4266            ),
4267        );
4268        write_archive(
4269            directory.path(),
4270            "0002_right",
4271            right_source,
4272            &archive(
4273                "0002_right",
4274                Vec::new(),
4275                right_source,
4276                LegacySchemaEffect::Snapshot,
4277                "0002_right",
4278                &right_hash,
4279            ),
4280        );
4281        write_archive(
4282            directory.path(),
4283            "0003_merge",
4284            merge_source,
4285            &archive(
4286                "0003_merge",
4287                vec![dependency("0001_left"), dependency("0002_right")],
4288                merge_source,
4289                LegacySchemaEffect::UnchangedNoop,
4290                "0001_left",
4291                &left_hash,
4292            ),
4293        );
4294
4295        let error = load_adoption_history(directory.path())
4296            .expect_err("a no-op merge cannot choose between divergent authorities");
4297        assert!(error.to_string().contains("divergent snapshot authorities"));
4298    }
4299
4300    #[cfg(unix)]
4301    #[test]
4302    fn supplied_root_symlink_is_followed_once_and_later_replacement_cannot_redirect_reads() {
4303        use std::os::unix::fs::symlink;
4304
4305        let original = tempfile::tempdir().expect("original legacy directory");
4306        let replacement = tempfile::tempdir().expect("replacement legacy directory");
4307        let links = tempfile::tempdir().expect("link parent");
4308        let original_directory = original.path().join("legacy");
4309        let replacement_directory = replacement.path().join("legacy");
4310        fs::create_dir(&original_directory).expect("original legacy root");
4311        fs::create_dir(&replacement_directory).expect("replacement legacy root");
4312        let schema = "define\nattribute original-name, value string;\n";
4313        write_snapshot_migration(
4314            &original_directory,
4315            "0001_initial",
4316            Vec::new(),
4317            "class Initial: pass\n",
4318            "v0001",
4319            schema,
4320        );
4321        write_snapshot_migration(
4322            &replacement_directory,
4323            "0001_initial",
4324            Vec::new(),
4325            "class Initial: pass\n",
4326            "v0001",
4327            "define\nattribute replacement-name, value string;\n",
4328        );
4329        let link = links.path().join("legacy");
4330        symlink(&original_directory, &link).expect("root symlink");
4331
4332        let history = load_adoption_history(&link).expect("root symlink is a supported input");
4333        fs::remove_file(&link).expect("old root symlink removes");
4334        symlink(&replacement_directory, &link).expect("replacement root symlink");
4335
4336        let reconstructed = reconstruct_legacy_head(&history)
4337            .expect("retained authority reconstructs the original tree");
4338        assert_eq!(reconstructed.schema_typeql(), schema);
4339    }
4340
4341    #[cfg(unix)]
4342    #[test]
4343    fn retained_root_survives_atomic_path_replacement_without_mixing_trees() {
4344        let parent = tempfile::tempdir().expect("legacy parent");
4345        let configured = parent.path().join("legacy");
4346        let held = parent.path().join("held");
4347        fs::create_dir(&configured).expect("legacy root");
4348        let schema = "define\nattribute held-name, value string;\n";
4349        write_snapshot_migration(
4350            &configured,
4351            "0001_initial",
4352            Vec::new(),
4353            "class Initial: pass\n",
4354            "v0001",
4355            schema,
4356        );
4357        let authority = LegacyDirectoryAuthority::open_root(&configured)
4358            .expect("legacy root authority retains");
4359        fs::rename(&configured, &held).expect("original root moves");
4360        fs::create_dir(&configured).expect("replacement root");
4361        write_snapshot_migration(
4362            &configured,
4363            "0001_initial",
4364            Vec::new(),
4365            "class Initial: pass\n",
4366            "v0001",
4367            "define\nattribute replacement-name, value string;\n",
4368        );
4369
4370        let history = load_adoption_history_in(authority).expect("held history loads coherently");
4371        let reconstructed = reconstruct_legacy_head(&history).expect("held snapshot reconstructs");
4372        assert_eq!(reconstructed.schema_typeql(), schema);
4373    }
4374
4375    #[cfg(unix)]
4376    #[test]
4377    fn retained_publication_never_lands_in_an_ambient_replacement_root() {
4378        let parent = tempfile::tempdir().expect("legacy parent");
4379        let configured = parent.path().join("legacy");
4380        let held = parent.path().join("held");
4381        fs::create_dir(&configured).expect("legacy root");
4382        let authority = LegacyDirectoryAuthority::open_root(&configured)
4383            .expect("legacy root authority retains");
4384        fs::rename(&configured, &held).expect("original root moves");
4385        fs::create_dir(&configured).expect("replacement root");
4386
4387        authority
4388            .write_atomic_no_replace("authority.json", b"held")
4389            .expect("publication stays on the retained root");
4390
4391        assert_eq!(
4392            fs::read(held.join("authority.json")).expect("held publication reads"),
4393            b"held"
4394        );
4395        assert!(!configured.join("authority.json").exists());
4396    }
4397
4398    #[test]
4399    fn journal_swap_at_removal_is_restored_and_never_deleted() {
4400        let directory = tempfile::tempdir().expect("journal directory");
4401        let authority = LegacyDirectoryAuthority::open_root(directory.path())
4402            .expect("journal authority retains");
4403        let journal = directory.path().join(CONVERSION_JOURNAL);
4404        let held = directory.path().join("held-original.json");
4405        let expected_bytes = b"expected journal\n";
4406        let replacement_bytes = b"attacker journal\n";
4407        fs::write(&journal, expected_bytes).expect("expected journal writes");
4408        let expected = authority
4409            .inspect_relative(Path::new(CONVERSION_JOURNAL), None)
4410            .expect("journal inspection succeeds")
4411            .expect("journal exists");
4412        let hook_journal = journal.clone();
4413        let hook_held = held.clone();
4414        TEST_BEFORE_JOURNAL_QUARANTINE.with(|hook| {
4415            *hook.borrow_mut() = Some(Box::new(move || {
4416                fs::rename(&hook_journal, &hook_held).expect("expected journal moves");
4417                fs::write(&hook_journal, replacement_bytes).expect("replacement journal writes");
4418            }));
4419        });
4420
4421        let removed = authority
4422            .remove_if_matches(CONVERSION_JOURNAL, &expected, expected_bytes)
4423            .expect("mismatched quarantine restores without replacement");
4424
4425        assert!(!removed);
4426        assert_eq!(
4427            fs::read(&journal).expect("replacement remains at public name"),
4428            replacement_bytes
4429        );
4430        assert_eq!(
4431            fs::read(&held).expect("original was moved by the test attacker"),
4432            expected_bytes
4433        );
4434        assert!(
4435            fs::read_dir(directory.path())
4436                .expect("directory reads")
4437                .all(|entry| !entry
4438                    .expect("entry reads")
4439                    .file_name()
4440                    .to_string_lossy()
4441                    .starts_with(".tb-adopt-rm-"))
4442        );
4443    }
4444
4445    #[cfg(unix)]
4446    #[test]
4447    fn in_place_source_change_is_rejected_even_when_parent_directory_is_unchanged() {
4448        let directory = tempfile::tempdir().expect("legacy directory");
4449        let path = directory.path().join("0001_initial.py");
4450        fs::write(&path, b"original-bytes\n").expect("original source");
4451        let authority = LegacyDirectoryAuthority::open_root(directory.path())
4452            .expect("legacy root authority retains");
4453        let capture = authority.capture().expect("root captures");
4454        let source = capture
4455            .entries
4456            .iter()
4457            .find(|entry| entry.name == OsStr::new("0001_initial.py"))
4458            .expect("source observation");
4459        let directory_revision = authority.directory_revision().expect("root revision");
4460        fs::write(&path, b"replaced-bytes\n").expect("same-length in-place replacement");
4461        authority
4462            .require_directory_revision(&directory_revision)
4463            .expect("in-place body write does not mutate the parent directory");
4464
4465        let error = authority
4466            .read_relative_bounded(Path::new("0001_initial.py"), 1024, Some(source))
4467            .expect_err("captured file revision rejects in-place content drift");
4468        assert!(
4469            error
4470                .to_string()
4471                .contains("changed after bounded directory enumeration")
4472        );
4473    }
4474
4475    #[test]
4476    fn recognized_root_body_digest_closes_metadata_equality_seam() {
4477        let directory = tempfile::tempdir().expect("legacy directory");
4478        write_snapshot_migration(
4479            directory.path(),
4480            "0001_initial",
4481            Vec::new(),
4482            "class Initial: pass\n",
4483            "v0001",
4484            "define\nattribute root-digest, value string;\n",
4485        );
4486        let mut history = load_adoption_history(directory.path()).expect("history loads");
4487        fs::write(
4488            directory.path().join("0001_initial.py"),
4489            "class Changed: pass\n",
4490        )
4491        .expect("same-length source body changes");
4492
4493        // Model a filesystem whose observable revision tuple aliases the old
4494        // and new body: preserve the originally captured digest while updating
4495        // only the test seam's metadata observation to the current revision.
4496        let observed = history
4497            .directory
4498            .inspect_relative(Path::new("0001_initial.py"), None)
4499            .expect("source inspects")
4500            .expect("source remains present");
4501        let captured = history
4502            .directory_capture
4503            .entries
4504            .iter_mut()
4505            .find(|entry| entry.name == OsStr::new("0001_initial.py"))
4506            .expect("source capture exists");
4507        *captured = observed;
4508
4509        let error = history
4510            .require_unchanged()
4511            .expect_err("exact root body digest must reject aliased metadata")
4512            .to_string();
4513        assert!(
4514            error.contains("recognized legacy root file body changed"),
4515            "{error}"
4516        );
4517    }
4518
4519    #[cfg(unix)]
4520    #[test]
4521    fn recognized_root_rename_is_rejected_even_when_directory_mtime_is_restored() {
4522        use std::fs::{File, FileTimes};
4523
4524        let directory = tempfile::tempdir().expect("legacy directory");
4525        write_snapshot_migration(
4526            directory.path(),
4527            "0001_initial",
4528            Vec::new(),
4529            "class Initial: pass\n",
4530            "v0001",
4531            "define\nattribute root-membership, value string;\n",
4532        );
4533        let padding = directory.path().join("padding-file.json");
4534        fs::write(&padding, b"unrelated padding\n").expect("padding file");
4535        let history = load_adoption_history(directory.path()).expect("history loads");
4536        let modified = fs::metadata(directory.path())
4537            .expect("root metadata")
4538            .modified()
4539            .expect("root modification time");
4540
4541        fs::rename(&padding, directory.path().join("0009_deleted.json"))
4542            .expect("padding becomes a released sidecar name");
4543        File::open(directory.path())
4544            .expect("root opens")
4545            .set_times(FileTimes::new().set_modified(modified))
4546            .expect("root mtime restores");
4547
4548        let error = history
4549            .require_unchanged()
4550            .expect_err("filtered root membership must not rely on restorable mtime")
4551            .to_string();
4552        assert!(
4553            error.contains("recognized legacy migration membership changed"),
4554            "{error}"
4555        );
4556    }
4557
4558    #[cfg(unix)]
4559    #[test]
4560    fn snapshot_version_rename_is_rejected_even_when_directory_mtime_is_restored() {
4561        use std::fs::{File, FileTimes};
4562
4563        let directory = tempfile::tempdir().expect("snapshot directory");
4564        fs::create_dir(directory.path().join("x0009")).expect("padding snapshot directory");
4565        let authority = LegacyDirectoryAuthority::open_root(directory.path())
4566            .expect("snapshot authority retains");
4567        let mut capture = authority.capture().expect("snapshot root captures");
4568        capture
4569            .entries
4570            .retain(|entry| entry.name.to_str().is_some_and(is_snapshot_version));
4571        let modified = fs::metadata(directory.path())
4572            .expect("snapshot root metadata")
4573            .modified()
4574            .expect("snapshot root modification time");
4575
4576        fs::rename(
4577            directory.path().join("x0009"),
4578            directory.path().join("v0009"),
4579        )
4580        .expect("padding becomes a snapshot version");
4581        File::open(directory.path())
4582            .expect("snapshot root opens")
4583            .set_times(FileTimes::new().set_modified(modified))
4584            .expect("snapshot root mtime restores");
4585
4586        let error = authority
4587            .require_snapshot_version_capture(&capture)
4588            .expect_err("filtered snapshot membership must not rely on restorable mtime")
4589            .to_string();
4590        assert!(
4591            error.contains("snapshot version membership changed"),
4592            "{error}"
4593        );
4594    }
4595
4596    #[test]
4597    fn require_unchanged_reverifies_snapshot_file_contents() {
4598        let directory = tempfile::tempdir().expect("legacy directory");
4599        let original = "define\nattribute original-name, value string;\n";
4600        write_snapshot_migration(
4601            directory.path(),
4602            "0001_initial",
4603            Vec::new(),
4604            "class Initial: pass\n",
4605            "v0001",
4606            original,
4607        );
4608        let history = load_adoption_history(directory.path()).expect("history loads");
4609        assert_eq!(
4610            reconstruct_legacy_head(&history)
4611                .expect("initial snapshot reconstructs")
4612                .schema_typeql(),
4613            original
4614        );
4615        fs::write(
4616            directory.path().join("snapshots/v0001/schema.tql"),
4617            "define\nattribute replaced-name, value string;\n",
4618        )
4619        .expect("same-length snapshot body changes in place");
4620
4621        let error = history
4622            .require_unchanged()
4623            .expect_err("use-point validation must rehash snapshot children")
4624            .to_string();
4625        assert!(error.contains("snapshot file hash mismatch"), "{error}");
4626    }
4627
4628    #[test]
4629    fn released_snapshot_ignores_unbound_pycache_children() {
4630        let directory = tempfile::tempdir().expect("legacy directory");
4631        let schema = "define\nattribute imported-name, value string;\n";
4632        write_snapshot_migration(
4633            directory.path(),
4634            "0001_initial",
4635            Vec::new(),
4636            "class Initial: pass\n",
4637            "v0001",
4638            schema,
4639        );
4640        let cache = directory.path().join("snapshots/v0001/__pycache__");
4641        fs::create_dir(&cache).expect("import cache directory");
4642        fs::write(cache.join("entities.cpython-313.pyc"), b"ambient cache")
4643            .expect("import cache body");
4644
4645        let history = load_adoption_history(directory.path()).expect("history loads");
4646        let reconstructed =
4647            reconstruct_legacy_head(&history).expect("unbound cache is not snapshot authority");
4648
4649        assert_eq!(reconstructed.schema_typeql(), schema);
4650    }
4651
4652    #[test]
4653    fn irrelevant_snapshot_manifest_change_during_scan_fails_closed() {
4654        let directory = tempfile::tempdir().expect("legacy directory");
4655        write_snapshot_migration(
4656            directory.path(),
4657            "0001_initial",
4658            Vec::new(),
4659            "class Initial: pass\n",
4660            "v0001",
4661            "define\nattribute scan-race, value string;\n",
4662        );
4663        write_snapshot(
4664            directory.path(),
4665            "v0009",
4666            "9999_irrelevant",
4667            "define\nattribute irrelevant, value string;\n",
4668        );
4669        let history = load_adoption_history(directory.path()).expect("history loads");
4670        let irrelevant_manifest = directory.path().join("snapshots/v0009/snapshot.json");
4671        TEST_AFTER_SNAPSHOT_SCAN.with(|hook| {
4672            *hook.borrow_mut() = Some(Box::new(move || {
4673                fs::write(&irrelevant_manifest, b"{}").expect("irrelevant manifest mutates");
4674            }));
4675        });
4676
4677        let error = reconstruct_legacy_head(&history)
4678            .expect_err("every scanned manifest must stay exact through authority selection")
4679            .to_string();
4680        assert!(
4681            error.contains("scanned legacy snapshot manifest changed after bounded scan"),
4682            "{error}"
4683        );
4684    }
4685
4686    #[cfg(unix)]
4687    #[test]
4688    fn snapshot_descendant_swap_is_rejected_before_reconstruction() {
4689        let directory = tempfile::tempdir().expect("legacy directory");
4690        write_snapshot_migration(
4691            directory.path(),
4692            "0001_initial",
4693            Vec::new(),
4694            "class Initial: pass\n",
4695            "v0001",
4696            "define\nattribute original-name, value string;\n",
4697        );
4698        let history = load_adoption_history(directory.path()).expect("history loads");
4699        let version = directory.path().join("snapshots/v0001");
4700        let held = directory.path().join("snapshots/v0001-held");
4701        fs::rename(&version, &held).expect("snapshot version moves");
4702        write_snapshot(
4703            directory.path(),
4704            "v0001",
4705            "0001_initial",
4706            "define\nattribute replacement-name, value string;\n",
4707        );
4708
4709        let error = reconstruct_legacy_head(&history)
4710            .expect_err("snapshot-root revision rejects the descendant swap");
4711        assert!(
4712            error
4713                .to_string()
4714                .contains("retained entry changed after bounded capture")
4715        );
4716    }
4717
4718    #[test]
4719    fn duplicate_snapshot_source_names_across_apps_are_ambiguous() {
4720        let directory = tempfile::tempdir().expect("legacy directory");
4721        let schema = "define\nattribute shared-name, value string;\n";
4722        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
4723        write_snapshot(directory.path(), "v0001", "0001_initial", schema);
4724        let directory_authority = LegacyDirectoryAuthority::open_root(directory.path())
4725            .expect("legacy root authority retains");
4726        let directory_capture = directory_authority.capture().expect("root captures");
4727        let snapshots_entry = directory_capture
4728            .entries
4729            .iter()
4730            .find(|entry| entry.name == OsStr::new("snapshots"))
4731            .expect("snapshots observation");
4732        let snapshots = directory_authority
4733            .open_child(snapshots_entry)
4734            .expect("snapshots retain");
4735        let snapshot_capture = snapshots.capture().expect("snapshot versions capture");
4736        let migrations = ["app_a", "app_b"]
4737            .into_iter()
4738            .map(|app_label| MigrationSpec {
4739                app_label: app_label.to_owned(),
4740                name: "0001_initial".to_owned(),
4741                dependencies: Vec::new(),
4742                operations: Vec::new(),
4743                checksum: Some("0123456789abcdef".to_owned()),
4744                source_sha256: None,
4745                reversible: false,
4746            })
4747            .collect::<Vec<_>>();
4748        let snapshot_authorities = ["app_a", "app_b"]
4749            .into_iter()
4750            .map(|app_label| {
4751                (
4752                    (app_label.to_owned(), "0001_initial".to_owned()),
4753                    SnapshotAuthority {
4754                        source: MigrationDependencySpec {
4755                            app_label: app_label.to_owned(),
4756                            migration_name: "0001_initial".to_owned(),
4757                        },
4758                        schema_hash: schema_hash.clone(),
4759                    },
4760                )
4761            })
4762            .collect();
4763        let history = LegacyAdoptionHistory {
4764            graph: MigrationGraph { migrations },
4765            directory: directory_authority,
4766            directory_capture,
4767            snapshots: Some((snapshots, snapshot_capture)),
4768            snapshot_authorities,
4769            root_file_digests: BTreeMap::new(),
4770            consumed_bytes: 0,
4771        };
4772
4773        let error = reconstruct_legacy_head(&history)
4774            .expect_err("an app-less V1 snapshot cannot authorize two app owners")
4775            .to_string();
4776        assert!(error.contains("ambiguous across app labels"), "{error}");
4777    }
4778
4779    #[test]
4780    fn native_load_rejects_cross_app_claims_on_one_snapshot_source() {
4781        let temporary = tempfile::tempdir().expect("temporary root");
4782        let directory = temporary.path().join("migrations");
4783        fs::create_dir(&directory).expect("legacy directory");
4784        let schema = "define\nattribute shared-source, value string;\n";
4785        let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
4786        write_snapshot(&directory, "v0001", "0001_shared", schema);
4787
4788        for (source_name, app_label) in [("0001_a", "app_a"), ("0001_b", "app_b")] {
4789            let source = "raise RuntimeError('released sidecar wins')\n";
4790            let sidecar = sidecar_spec(app_label, "0001_shared", Vec::new(), source, Vec::new());
4791            let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
4792            let archive = LegacySidecarAdoptionMetadata::new(
4793                source_name,
4794                app_label,
4795                "0001_shared",
4796                Vec::new(),
4797                migration_file_checksum(source),
4798                sidecar.checksum.clone(),
4799                hex_digest(Sha256::digest(source.as_bytes())),
4800                hex_digest(Sha256::digest(&sidecar_bytes)),
4801                LegacySchemaEffect::Snapshot,
4802                MigrationDependencySpec {
4803                    app_label: app_label.to_owned(),
4804                    migration_name: "0001_shared".to_owned(),
4805                },
4806                &schema_hash,
4807            )
4808            .expect("cross-app sidecar archive");
4809            write_sidecar_archive(&directory, source_name, source, &sidecar, &archive);
4810        }
4811
4812        let error = load_adoption_history(&directory)
4813            .expect_err("native load must reject app-less snapshot owner ambiguity")
4814            .to_string();
4815        assert!(error.contains("ambiguous across app labels"), "{error}");
4816    }
4817
4818    #[test]
4819    fn irrelevant_snapshot_children_are_not_recursively_enumerated() {
4820        let directory = tempfile::tempdir().expect("legacy directory");
4821        let schema = "define\nattribute relevant-name, value string;\n";
4822        write_snapshot_migration(
4823            directory.path(),
4824            "0001_initial",
4825            Vec::new(),
4826            "class Initial: pass\n",
4827            "v0001",
4828            schema,
4829        );
4830        for index in 1000..1128 {
4831            let version = format!("v{index:04}");
4832            write_snapshot(
4833                directory.path(),
4834                &version,
4835                "9999_irrelevant",
4836                "define\nattribute irrelevant-name, value string;\n",
4837            );
4838            let snapshot = directory.path().join("snapshots").join(version);
4839            for extra in 0..32 {
4840                fs::write(snapshot.join(format!("extra-{extra:02}.txt")), b"ignored")
4841                    .expect("irrelevant extra writes");
4842            }
4843        }
4844        let history = load_adoption_history(directory.path()).expect("history loads");
4845        let top_level_revalidation_entries = history.directory_capture.entries.len()
4846            + history
4847                .snapshots
4848                .as_ref()
4849                .expect("snapshot authority")
4850                .1
4851                .entries
4852                .len();
4853        reset_captured_entry_count();
4854        let started = std::time::Instant::now();
4855
4856        let reconstructed = reconstruct_legacy_head(&history).expect("relevant snapshot verifies");
4857
4858        assert_eq!(reconstructed.schema_typeql(), schema);
4859        assert_eq!(
4860            captured_entry_count(),
4861            3 * top_level_revalidation_entries + 2,
4862            "only exact root/version revalidation and the relevant snapshot children may be enumerated"
4863        );
4864        assert!(started.elapsed() < std::time::Duration::from_secs(10));
4865    }
4866
4867    #[test]
4868    fn ceiling_scale_reversed_chain_validates_resolves_and_finds_head_within_budget() {
4869        const NODE_COUNT: usize = 65_536;
4870        let schema_hash = "a".repeat(64);
4871        let root_authority = SnapshotAuthority {
4872            source: MigrationDependencySpec {
4873                app_label: "app".to_owned(),
4874                migration_name: "node_00000".to_owned(),
4875            },
4876            schema_hash,
4877        };
4878        let mut migrations = Vec::with_capacity(NODE_COUNT);
4879        let mut bindings = BTreeMap::new();
4880        for index in (0..NODE_COUNT).rev() {
4881            let name = format!("node_{index:05}");
4882            let dependencies = if index == 0 {
4883                Vec::new()
4884            } else {
4885                vec![MigrationDependencySpec {
4886                    app_label: "app".to_owned(),
4887                    migration_name: format!("node_{:05}", index - 1),
4888                }]
4889            };
4890            migrations.push(MigrationSpec {
4891                app_label: "app".to_owned(),
4892                name: name.clone(),
4893                dependencies,
4894                operations: Vec::new(),
4895                checksum: Some(format!("checksum-{index}")),
4896                source_sha256: None,
4897                reversible: false,
4898            });
4899            bindings.insert(
4900                ("app".to_owned(), name),
4901                SchemaBinding {
4902                    effect: if index == 0 {
4903                        LegacySchemaEffect::Snapshot
4904                    } else {
4905                        LegacySchemaEffect::UnchangedNoop
4906                    },
4907                    authority: root_authority.clone(),
4908                },
4909            );
4910        }
4911        let graph = MigrationGraph { migrations };
4912        let started = std::time::Instant::now();
4913        assert!(validate_graph(&graph, &[]).is_empty());
4914        let resolved = resolve_schema_bindings(&graph, &bindings).expect("chain resolves");
4915        let directory = tempfile::tempdir().expect("authority directory");
4916        let directory_authority =
4917            LegacyDirectoryAuthority::open_root(directory.path()).expect("directory authority");
4918        let directory_capture = directory_authority.capture().expect("directory captures");
4919        let history = LegacyAdoptionHistory {
4920            graph,
4921            directory: directory_authority,
4922            directory_capture,
4923            snapshots: None,
4924            snapshot_authorities: resolved,
4925            root_file_digests: BTreeMap::new(),
4926            consumed_bytes: 0,
4927        };
4928        let heads = history.heads().expect("head resolves");
4929
4930        assert_eq!(heads.len(), 1);
4931        assert_eq!(heads[0].name, "node_65535");
4932        assert!(started.elapsed() < std::time::Duration::from_secs(10));
4933    }
4934
4935    #[test]
4936    fn executable_sidecar_alone_is_not_adoption_graph_authority() {
4937        let directory = tempfile::tempdir().expect("legacy directory");
4938        let source = "class Migration: pass\n";
4939        fs::write(directory.path().join("0001_initial.py"), source).expect("Python source");
4940        let spec = MigrationSpec {
4941            app_label: "example".to_owned(),
4942            name: "0001_initial".to_owned(),
4943            dependencies: Vec::new(),
4944            operations: Vec::new(),
4945            checksum: Some(migration_file_checksum(source)),
4946            source_sha256: None,
4947            reversible: true,
4948        };
4949        fs::write(
4950            directory.path().join("0001_initial.json"),
4951            serde_json::to_vec(&spec).expect("sidecar JSON"),
4952        )
4953        .expect("sidecar writes");
4954
4955        let error = load_adoption_history(directory.path())
4956            .expect_err("independently editable sidecar dependencies are insufficient");
4957        assert!(error.to_string().contains("adoption metadata"));
4958    }
4959}