use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use m1nd_core::error::{M1ndError, M1ndResult};
use serde::{Deserialize, Serialize};
use crate::util::now_ms;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Destination {
Project,
Medulla,
AmbiguousStay,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimPlan {
pub file_name: String,
pub destination: Destination,
pub has_origin_brain: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GhostPointer {
pub entry: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationPlan {
pub claims: Vec<ClaimPlan>,
pub ghost_pointers: Vec<GhostPointer>,
pub baseline_count: usize,
pub project_count: usize,
pub medulla_count: usize,
pub count_conserved: bool,
pub medulla_dir: String,
pub project_dir: String,
}
pub struct MedullaMigration {
medulla_dir: PathBuf,
project_dir: PathBuf,
ingest_roots_path: PathBuf,
project_origin: String,
owner_guard_port: u16,
}
const BACKUP_PREFIX: &str = ".m5a-backup-";
const SERVED_OWNER_PORT: u16 = 1338;
const MANIFEST_NAME: &str = "manifest.json";
const ROOTS_BACKUP_SUBDIR: &str = "ingest-roots";
const DEST_PREEXISTING_SUBDIR: &str = "project-preexisting";
const PRE_ROLLBACK_SUBDIR: &str = "pre-rollback-live";
impl MedullaMigration {
pub fn new(
medulla_dir: impl Into<PathBuf>,
project_dir: impl Into<PathBuf>,
ingest_roots_path: impl Into<PathBuf>,
project_origin: impl Into<String>,
) -> Self {
Self {
medulla_dir: medulla_dir.into(),
project_dir: project_dir.into(),
ingest_roots_path: ingest_roots_path.into(),
project_origin: project_origin.into(),
owner_guard_port: SERVED_OWNER_PORT,
}
}
pub fn with_owner_guard_port(mut self, port: u16) -> Self {
self.owner_guard_port = port;
self
}
fn ensure_owner_down(&self) -> M1ndResult<()> {
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
let addr = SocketAddr::from(([127, 0, 0, 1], self.owner_guard_port));
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
return Err(M1ndError::InvalidParams {
tool: "medulla_migration".into(),
detail: format!(
"a served owner is listening on 127.0.0.1:{} — stop the served owner first; \
the offline migration must not run against a live owner",
self.owner_guard_port
),
});
}
Ok(())
}
fn live_claims(&self) -> M1ndResult<Vec<PathBuf>> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.medulla_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(M1ndError::Io(e)),
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if path.is_file() && name.ends_with(".light.md") {
out.push(path);
}
}
out.sort();
Ok(out)
}
fn classify(text: &str) -> (Destination, String) {
let lower = text.to_ascii_lowercase();
const HARD_DOCTRINE_MARKERS: &[&str] = &[
"doctrine",
"doutrina",
"cross-project",
"transversal",
"universal across",
"across any project",
"every m1nd caller",
"every agent",
"todo agente",
"founder decision",
"sealed by max",
"product vocabulary",
"maintainer preference",
];
if let Some(hit) = HARD_DOCTRINE_MARKERS.iter().find(|m| lower.contains(*m)) {
return (
Destination::Medulla,
format!(
"cross-project doctrine: mentions '{hit}' — stays on the medulla \
even though it cites evidence"
),
);
}
let has_code_evidence = text.lines().any(|l| {
let t = l.trim();
t.starts_with("[𝔻 evidence:")
&& (t.contains(".rs")
|| t.contains(".ts")
|| t.contains(".py")
|| t.contains(".js")
|| t.contains(".md")
|| t.contains('/'))
});
if has_code_evidence {
return (
Destination::Project,
"code-anchored: carries a [𝔻 evidence:] marker to a repo path".into(),
);
}
const PROJECT_MARKERS: &[&str] = &[
"slice",
"shipped",
"hall fix",
"hall-fix",
"flake",
"ci flake",
"ladder",
"pr #",
"merged",
"handler",
"invariant tt-",
"regression",
"gate",
];
if let Some(hit) = PROJECT_MARKERS.iter().find(|m| lower.contains(*m)) {
return (
Destination::Project,
format!("m1nd-repo fact: mentions '{hit}' (ship/fix/flake vocabulary)"),
);
}
const DOCTRINE_MARKERS: &[&str] = &[
"doctrine",
"preference",
"prefers",
"maintainer",
"vocabulary",
"cross-project",
"always",
"never ",
"rule:",
"policy",
];
if let Some(hit) = DOCTRINE_MARKERS.iter().find(|m| lower.contains(*m)) {
return (
Destination::Medulla,
format!("doctrine/preference: mentions '{hit}' — already home on the medulla"),
);
}
(
Destination::AmbiguousStay,
"ambiguous: no clear repo-fact or doctrine signal — stays, flagged for maintainer triage"
.into(),
)
}
fn has_origin_brain(text: &str) -> bool {
text.lines()
.any(|l| l.trim_start().starts_with("Origin-Brain:"))
}
fn sweep_ingest_roots(&self) -> M1ndResult<(Vec<GhostPointer>, Option<Vec<String>>)> {
let text = match std::fs::read_to_string(&self.ingest_roots_path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), None)),
Err(e) => return Err(M1ndError::Io(e)),
};
let roots: Vec<String> = serde_json::from_str(&text).map_err(M1ndError::Serde)?;
let mem_dir_str = self.medulla_dir.to_string_lossy().to_string();
let mut ghosts = Vec::new();
let mut swept: Vec<String> = Vec::new();
let mut collapsed_dir = false;
for entry in &roots {
let is_light_file = entry.ends_with(".light.md");
if is_light_file {
if !Path::new(entry).exists() {
ghosts.push(GhostPointer {
entry: entry.clone(),
reason: "dangling: the .light.md file no longer exists".into(),
});
} else {
ghosts.push(GhostPointer {
entry: entry.clone(),
reason: "collapsed: per-file .light.md pointer folds into the dir root"
.into(),
});
collapsed_dir = true;
}
} else {
swept.push(entry.clone());
}
}
if collapsed_dir && !swept.iter().any(|r| r == &mem_dir_str) {
swept.push(mem_dir_str);
}
if ghosts.is_empty() {
Ok((ghosts, None))
} else {
Ok((ghosts, Some(swept)))
}
}
pub fn plan(&self) -> M1ndResult<MigrationPlan> {
let files = self.live_claims()?;
let baseline_count = files.len();
let mut claims = Vec::with_capacity(baseline_count);
let mut project_count = 0usize;
let mut medulla_count = 0usize;
for path in &files {
let text = std::fs::read_to_string(path).map_err(M1ndError::Io)?;
let (destination, reason) = Self::classify(&text);
match destination {
Destination::Project => project_count += 1,
Destination::Medulla | Destination::AmbiguousStay => medulla_count += 1,
}
claims.push(ClaimPlan {
file_name: path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
destination,
has_origin_brain: Self::has_origin_brain(&text),
reason,
});
}
let (ghost_pointers, _swept) = self.sweep_ingest_roots()?;
Ok(MigrationPlan {
claims,
ghost_pointers,
baseline_count,
project_count,
medulla_count,
count_conserved: baseline_count == project_count + medulla_count,
medulla_dir: self.medulla_dir.to_string_lossy().to_string(),
project_dir: self.project_dir.to_string_lossy().to_string(),
})
}
fn backup(&self) -> M1ndResult<PathBuf> {
let backup_dir = self
.medulla_dir
.join(format!("{BACKUP_PREFIX}{}", now_ms()));
std::fs::create_dir_all(&backup_dir).map_err(M1ndError::Io)?;
copy_tree(&self.medulla_dir, &backup_dir, &backup_dir)?;
Ok(backup_dir)
}
fn write_manifest(&self, backup_dir: &Path, moved_files: &[String]) -> M1ndResult<()> {
let json = serde_json::to_string_pretty(moved_files).map_err(M1ndError::Serde)?;
std::fs::write(backup_dir.join(MANIFEST_NAME), json).map_err(M1ndError::Io)?;
Ok(())
}
fn read_manifest(backup_dir: &Path) -> Vec<String> {
std::fs::read_to_string(backup_dir.join(MANIFEST_NAME))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn backup_ingest_roots(&self, backup_dir: &Path) -> M1ndResult<()> {
if !self.ingest_roots_path.is_file() {
return Ok(());
}
let dir = backup_dir.join(ROOTS_BACKUP_SUBDIR);
std::fs::create_dir_all(&dir).map_err(M1ndError::Io)?;
let name = self
.ingest_roots_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_else(|| "ingest_roots.json".into());
std::fs::copy(&self.ingest_roots_path, dir.join(name)).map_err(M1ndError::Io)?;
Ok(())
}
fn restore_ingest_roots(&self, backup_dir: &Path) -> M1ndResult<()> {
let name = self
.ingest_roots_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_else(|| "ingest_roots.json".into());
let backed_up = backup_dir.join(ROOTS_BACKUP_SUBDIR).join(name);
if backed_up.is_file() {
std::fs::copy(&backed_up, &self.ingest_roots_path).map_err(M1ndError::Io)?;
}
Ok(())
}
pub fn apply(&self) -> M1ndResult<MigrationReceipt> {
self.ensure_owner_down()?;
let plan = self.plan()?;
let collisions: Vec<String> = plan
.claims
.iter()
.filter(|c| c.destination == Destination::Project)
.map(|c| c.file_name.clone())
.filter(|name| self.project_dir.join(name).exists())
.collect();
if !collisions.is_empty() {
return Err(M1ndError::InvalidParams {
tool: "medulla_migration".into(),
detail: format!(
"destination name collision — the project store already holds: {}; \
refusing to overwrite. Resolve these files before migrating.",
collisions.join(", ")
),
});
}
let nothing_to_move = plan.project_count == 0;
let nothing_to_stamp = plan.claims.iter().all(|c| c.has_origin_brain);
if nothing_to_move && nothing_to_stamp {
let medulla_after = self.live_claims()?.len();
let project_after = count_project_claims(&self.project_dir);
return Ok(MigrationReceipt {
backup_dir: String::new(),
moved_to_project: 0,
moved_files: Vec::new(),
stamped_medulla: 0,
ghosts_pruned: 0,
baseline_count: plan.baseline_count,
medulla_after,
project_after,
count_conserved: true,
content_conserved: true,
already_migrated: true,
});
}
let backup_dir = self.backup()?;
self.backup_ingest_roots(&backup_dir)?;
std::fs::create_dir_all(&self.project_dir).map_err(M1ndError::Io)?;
let mut moved_files: Vec<String> = Vec::new();
let mut stamped = 0usize;
for claim in &plan.claims {
let src = self.medulla_dir.join(&claim.file_name);
let text = std::fs::read_to_string(&src).map_err(M1ndError::Io)?;
match claim.destination {
Destination::Project => {
let stamped_text = stamp_origin_brain(&text, &self.project_origin);
let dst = self.project_dir.join(&claim.file_name);
std::fs::write(&dst, stamped_text).map_err(M1ndError::Io)?;
std::fs::remove_file(&src).map_err(M1ndError::Io)?;
moved_files.push(claim.file_name.clone());
}
Destination::Medulla | Destination::AmbiguousStay => {
if !claim.has_origin_brain {
let stamped_text = stamp_origin_brain(&text, "medulla");
std::fs::write(&src, stamped_text).map_err(M1ndError::Io)?;
stamped += 1;
}
}
}
}
moved_files.sort();
self.write_manifest(&backup_dir, &moved_files)?;
let (_ghosts, swept) = self.sweep_ingest_roots()?;
if let Some(roots) = swept {
let json = serde_json::to_string_pretty(&roots).map_err(M1ndError::Serde)?;
std::fs::write(&self.ingest_roots_path, json).map_err(M1ndError::Io)?;
}
let medulla_after = self.live_claims()?.len();
let project_after = count_project_claims(&self.project_dir);
let count_conserved = plan.baseline_count == medulla_after + project_after;
let content_conserved = count_conserved
&& moved_files.iter().all(|name| {
self.project_dir.join(name).exists() && !self.medulla_dir.join(name).exists()
})
&& plan.baseline_count == medulla_after + moved_files.len();
Ok(MigrationReceipt {
backup_dir: backup_dir.to_string_lossy().to_string(),
moved_to_project: moved_files.len(),
moved_files,
stamped_medulla: stamped,
ghosts_pruned: plan.ghost_pointers.len(),
baseline_count: plan.baseline_count,
medulla_after,
project_after,
count_conserved,
content_conserved,
already_migrated: false,
})
}
pub fn rollback(
&self,
backup_dir: &str,
moved_file_names: &[String],
) -> M1ndResult<Vec<String>> {
self.ensure_owner_down()?;
let backup = PathBuf::from(backup_dir);
if !backup.is_dir() {
return Err(M1ndError::InvalidParams {
tool: "medulla_migration".into(),
detail: format!("backup dir '{backup_dir}' does not exist — cannot rollback"),
});
}
let manifest = Self::read_manifest(&backup);
let moved: &[String] = if manifest.is_empty() {
moved_file_names
} else {
&manifest
};
let snapshot = backup.join(PRE_ROLLBACK_SUBDIR);
if snapshot.exists() {
std::fs::remove_dir_all(&snapshot).map_err(M1ndError::Io)?;
}
copy_tree(&self.medulla_dir, &snapshot, &backup)?;
let mut removed: Vec<String> = Vec::new();
for name in moved {
let dst = self.project_dir.join(name);
if dst.exists() {
std::fs::remove_file(&dst).map_err(M1ndError::Io)?;
removed.push(name.clone());
}
}
for path in self.live_claims()? {
std::fs::remove_file(&path).map_err(M1ndError::Io)?;
}
restore_tree(&backup, &self.medulla_dir)?;
self.restore_ingest_roots(&backup)?;
Ok(removed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationReceipt {
pub backup_dir: String,
pub moved_to_project: usize,
#[serde(default)]
pub moved_files: Vec<String>,
pub stamped_medulla: usize,
pub ghosts_pruned: usize,
pub baseline_count: usize,
pub medulla_after: usize,
pub project_after: usize,
pub count_conserved: bool,
#[serde(default)]
pub content_conserved: bool,
#[serde(default)]
pub already_migrated: bool,
}
fn count_project_claims(project_dir: &Path) -> usize {
std::fs::read_dir(project_dir)
.map(|e| {
e.flatten()
.filter(|d| {
d.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| !n.starts_with('.') && n.ends_with(".light.md"))
})
.count()
})
.unwrap_or(0)
}
fn stamp_origin_brain(text: &str, value: &str) -> String {
if text
.lines()
.any(|l| l.trim_start().starts_with("Origin-Brain:"))
{
return text.to_string();
}
let mut out = String::with_capacity(text.len() + 40);
let mut inserted = false;
let mut seen_open_fence = false;
for line in text.lines() {
out.push_str(line);
out.push('\n');
if inserted {
continue;
}
let trimmed = line.trim();
if trimmed == "---" && !seen_open_fence {
seen_open_fence = true;
continue;
}
if seen_open_fence && trimmed.starts_with("Source-Agent:") {
out.push_str(&format!("Origin-Brain: {value}\n"));
inserted = true;
}
}
if !inserted && seen_open_fence {
let mut rebuilt = String::with_capacity(out.len() + 40);
let mut done = false;
for line in out.lines() {
rebuilt.push_str(line);
rebuilt.push('\n');
if !done && line.trim() == "---" {
rebuilt.push_str(&format!("Origin-Brain: {value}\n"));
done = true;
}
}
return rebuilt;
}
out
}
fn copy_tree(src: &Path, dst: &Path, backup_self: &Path) -> M1ndResult<()> {
std::fs::create_dir_all(dst).map_err(M1ndError::Io)?;
for entry in std::fs::read_dir(src).map_err(M1ndError::Io)?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with(BACKUP_PREFIX) || path == backup_self {
continue;
}
let target = dst.join(name);
if path.is_dir() {
copy_tree(&path, &target, backup_self)?;
} else {
std::fs::copy(&path, &target).map_err(M1ndError::Io)?;
}
}
Ok(())
}
fn restore_tree(backup: &Path, dst: &Path) -> M1ndResult<()> {
const METADATA: &[&str] = &[
MANIFEST_NAME,
ROOTS_BACKUP_SUBDIR,
DEST_PREEXISTING_SUBDIR,
PRE_ROLLBACK_SUBDIR,
];
std::fs::create_dir_all(dst).map_err(M1ndError::Io)?;
for entry in std::fs::read_dir(backup).map_err(M1ndError::Io)?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if METADATA.contains(&name) {
continue;
}
let target = dst.join(name);
if path.is_dir() {
restore_tree(&path, &target)?;
} else {
std::fs::copy(&path, &target).map_err(M1ndError::Io)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn light_doc(node: &str, source_agent: &str, body: &str) -> String {
format!(
"---\nProtocol: L1GHT/1.0\nNode: {node}\nState: authored\nCreated: 1700000000000\nSource-Agent: {source_agent}\n---\n\n# {node}\n\n## {node}\n\n{body}\n"
)
}
struct Scratch {
_tmp: tempfile::TempDir,
medulla: PathBuf,
project: PathBuf,
roots: PathBuf,
}
fn scratch() -> Scratch {
let tmp = tempfile::tempdir().expect("tempdir");
let medulla = tmp.path().join("runtime").join("agent-memory");
let project = tmp
.path()
.join("runtime")
.join("project-brains")
.join("fp")
.join("agent-memory");
std::fs::create_dir_all(&medulla).expect("medulla dir");
let roots = tmp.path().join("runtime").join("ingest_roots.json");
Scratch {
_tmp: tmp,
medulla,
project,
roots,
}
}
fn write_claim(dir: &Path, file: &str, contents: &str) {
std::fs::write(dir.join(file), contents).expect("write claim");
}
fn closed_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral")
.local_addr()
.unwrap()
.port()
}
fn scratch_mig(s: &Scratch) -> MedullaMigration {
MedullaMigration::new(&s.medulla, &s.project, &s.roots, "/path/to/repo")
.with_owner_guard_port(closed_port())
}
#[test]
fn cross_project_doctrine_with_evidence_stays_on_the_medulla() {
let doctrine = "# SixMoves\nDoutrina destilada: every agent applies the six \
analytical moves across any project.\n\n[𝔻 evidence: docs/HUMAN-LAYER-PRD.md]\n";
assert_eq!(
MedullaMigration::classify(doctrine).0,
Destination::Medulla,
"transversal doctrine that cites evidence must stay medulla"
);
let repo_fact = "# SliceShip\nThe reception slice shipped on main.\n\n\
[𝔻 evidence: m1nd-mcp/src/server.rs]\n";
assert_eq!(
MedullaMigration::classify(repo_fact).0,
Destination::Project,
"a repo fact with code evidence still moves to the project brain"
);
}
#[test]
fn plan_triages_mixed_store_count_conserving_and_pure_read() {
let s = scratch();
write_claim(
&s.medulla,
"sliceship.light.md",
&light_doc(
"SliceShip",
"closer-agent",
"The slice shipped.\n\n[⍂ entity: SliceShip]\n[𝔻 evidence: m1nd-mcp/src/server.rs]\n",
),
);
write_claim(
&s.medulla,
"maxpref.light.md",
&light_doc(
"MaxPref",
"orchestrator",
"The maintainer prefers pt-BR replies always.\n\n[⍂ entity: MaxPref]\n",
),
);
write_claim(
&s.medulla,
"mystery.light.md",
&light_doc(
"Mystery",
"someone",
"A thing happened once.\n\n[⍂ entity: Mystery]\n",
),
);
let ghost = s.medulla.join("deleted.light.md");
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![
s.medulla.to_string_lossy().to_string(),
ghost.to_string_lossy().to_string(),
])
.unwrap(),
)
.unwrap();
let before: BTreeMap<String, String> = std::fs::read_dir(&s.medulla)
.unwrap()
.flatten()
.filter(|e| e.path().is_file())
.map(|e| {
(
e.file_name().to_string_lossy().to_string(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
let mig = scratch_mig(&s);
let plan = mig.plan().expect("plan");
assert_eq!(plan.baseline_count, 3, "three live claims");
assert_eq!(plan.project_count, 1, "one repo fact → project");
assert_eq!(plan.medulla_count, 2, "doctrine + ambiguous stay");
assert!(plan.count_conserved, "baseline == project + medulla");
assert_eq!(plan.ghost_pointers.len(), 1, "one dangling ghost pruned");
assert!(
plan.claims.iter().all(|c| !c.has_origin_brain),
"RED: zero Origin-Brain fields today"
);
let after: BTreeMap<String, String> = std::fs::read_dir(&s.medulla)
.unwrap()
.flatten()
.filter(|e| e.path().is_file())
.map(|e| {
(
e.file_name().to_string_lossy().to_string(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
assert_eq!(before, after, "plan must not mutate the store (dry-run)");
}
#[test]
fn apply_splits_stores_stamps_origin_and_conserves_count() {
let s = scratch();
write_claim(
&s.medulla,
"sliceship.light.md",
&light_doc(
"SliceShip",
"closer",
"shipped.\n\n[⍂ entity: SliceShip]\n[𝔻 evidence: m1nd-mcp/src/x.rs]\n",
),
);
write_claim(
&s.medulla,
"maxpref.light.md",
&light_doc(
"MaxPref",
"orch",
"maintainer doctrine.\n\n[⍂ entity: MaxPref]\n",
),
);
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert!(receipt.count_conserved, "no claim lost");
assert_eq!(receipt.moved_to_project, 1);
assert_eq!(receipt.medulla_after, 1, "only doctrine stays");
assert_eq!(receipt.project_after, 1, "the repo fact moved");
let moved = std::fs::read_to_string(s.project.join("sliceship.light.md")).unwrap();
assert!(
moved.contains("Origin-Brain: /path/to/repo"),
"moved claim carries the project origin, got:\n{moved}"
);
assert!(
moved.contains("Source-Agent: closer"),
"original provenance preserved"
);
let stayed = std::fs::read_to_string(s.medulla.join("maxpref.light.md")).unwrap();
assert!(
stayed.contains("Origin-Brain: medulla"),
"doctrine claim stamped medulla, got:\n{stayed}"
);
}
#[test]
fn migrate_then_rollback_restores_original_bytes() {
let s = scratch();
let ship = light_doc(
"SliceShip",
"closer",
"shipped.\n\n[⍂ entity: SliceShip]\n[𝔻 evidence: m1nd-mcp/src/x.rs]\n",
);
let pref = light_doc(
"MaxPref",
"orch",
"maintainer doctrine.\n\n[⍂ entity: MaxPref]\n",
);
write_claim(&s.medulla, "sliceship.light.md", &ship);
write_claim(&s.medulla, "maxpref.light.md", &pref);
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let original: BTreeMap<String, String> = std::fs::read_dir(&s.medulla)
.unwrap()
.flatten()
.filter(|e| e.path().is_file())
.map(|e| {
(
e.file_name().to_string_lossy().to_string(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert!(receipt.count_conserved);
assert!(s.project.join("sliceship.light.md").exists());
mig.rollback(&receipt.backup_dir, &["sliceship.light.md".to_string()])
.expect("rollback");
let restored: BTreeMap<String, String> = std::fs::read_dir(&s.medulla)
.unwrap()
.flatten()
.filter(|e| {
e.path().is_file()
&& e.file_name()
.to_string_lossy()
.to_string()
.ends_with(".light.md")
})
.map(|e| {
(
e.file_name().to_string_lossy().to_string(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
assert_eq!(
original, restored,
"rollback must restore the medulla store byte-for-byte"
);
assert!(
!s.project.join("sliceship.light.md").exists(),
"rollback removed the moved claim from the project store"
);
}
#[test]
fn apply_is_memory_only_and_never_touches_the_code_graph() {
let s = scratch();
let graph_path = s.medulla.join("graph_snapshot.json");
let graph_bytes = "{\"schema\":\"stand-in-code-graph\",\"nodes\":6657}";
std::fs::write(&graph_path, graph_bytes).expect("seed code graph");
write_claim(
&s.medulla,
"sliceship.light.md",
&light_doc(
"SliceShip",
"closer",
"shipped.\n\n[⍂ entity: SliceShip]\n[𝔻 evidence: m1nd-mcp/src/x.rs]\n",
),
);
write_claim(
&s.medulla,
"maxpref.light.md",
&light_doc(
"MaxPref",
"orch",
"maintainer doctrine.\n\n[⍂ entity: MaxPref]\n",
),
);
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert!(
receipt.count_conserved,
"the memory split still conserves count"
);
assert!(
graph_path.exists(),
"the medulla's code graph must survive a memory-only migration"
);
assert_eq!(
std::fs::read_to_string(&graph_path).unwrap(),
graph_bytes,
"apply must not touch the medulla's code graph (option B: the owner keeps it)"
);
assert!(
!s.project.join("graph_snapshot.json").exists(),
"apply must not fabricate a code graph in the destination project store"
);
assert!(
s.project.join("sliceship.light.md").exists(),
"the repo fact still moved (memory-only migration is not a no-op)"
);
}
#[test]
fn ghost_pointer_sweep_prunes_dangling_and_collapses_live() {
let s = scratch();
write_claim(&s.medulla, "real.light.md", &light_doc("Real", "a", "x"));
let real = s.medulla.join("real.light.md");
let dangling = s.medulla.join("gone.light.md");
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![
s.medulla.to_string_lossy().to_string(),
real.to_string_lossy().to_string(),
dangling.to_string_lossy().to_string(),
])
.unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let (ghosts, swept) = mig.sweep_ingest_roots().expect("sweep");
assert_eq!(ghosts.len(), 2, "one dangling + one collapsed");
let roots = swept.expect("swept list");
assert!(
roots.iter().all(|r| !r.ends_with(".light.md")),
"no per-file .light.md pointers remain, got: {roots:?}"
);
assert!(
roots.contains(&s.medulla.to_string_lossy().to_string()),
"the dir root survives"
);
}
#[test]
fn apply_is_idempotent_on_already_migrated_store() {
let s = scratch();
write_claim(
&s.medulla,
"sliceship.light.md",
&light_doc(
"SliceShip",
"closer",
"shipped.\n\n[⍂ entity: SliceShip]\n[𝔻 evidence: m1nd-mcp/src/x.rs]\n",
),
);
write_claim(
&s.medulla,
"maxpref.light.md",
&light_doc(
"MaxPref",
"orch",
"maintainer doctrine.\n\n[⍂ entity: MaxPref]\n",
),
);
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let first = mig.apply().expect("first apply");
assert!(!first.already_migrated, "first apply actually migrates");
assert_eq!(first.moved_to_project, 1);
for e in std::fs::read_dir(&s.medulla).unwrap().flatten() {
if e.file_name().to_string_lossy().starts_with(BACKUP_PREFIX) {
std::fs::remove_dir_all(e.path()).unwrap();
}
}
let second = mig.apply().expect("second apply");
assert!(
second.already_migrated,
"a re-applied migration reports already_migrated"
);
assert_eq!(second.moved_to_project, 0, "nothing moved the second time");
assert!(
second.count_conserved,
"already-migrated must not report a count-conservation failure"
);
assert!(
second.backup_dir.is_empty(),
"already-migrated writes no backup, got: {}",
second.backup_dir
);
let backups: Vec<_> = std::fs::read_dir(&s.medulla)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(BACKUP_PREFIX))
.collect();
assert!(
backups.is_empty(),
"no fresh backup dir on the second apply"
);
}
#[test]
fn stamp_origin_brain_is_idempotent() {
let already = "---\nProtocol: L1GHT/1.0\nNode: X\nState: authored\nSource-Agent: a\nOrigin-Brain: medulla\n---\n\n# X\n";
assert_eq!(stamp_origin_brain(already, "medulla"), already);
let fresh =
"---\nProtocol: L1GHT/1.0\nNode: X\nState: authored\nSource-Agent: a\n---\n\n# X\n";
let stamped = stamp_origin_brain(fresh, "/path/to/repo");
assert!(stamped.contains("Origin-Brain: /path/to/repo"));
assert!(
stamped.find("Origin-Brain:").unwrap() > stamped.find("Source-Agent:").unwrap(),
"Origin-Brain lands after Source-Agent"
);
}
fn repo_fact(name: &str) -> String {
light_doc(
name,
"closer",
&format!("shipped.\n\n[⍂ entity: {name}]\n[𝔻 evidence: repo-alpha/src/x.rs]\n"),
)
}
fn doctrine(name: &str) -> String {
light_doc(
name,
"orch",
&format!("maintainer doctrine holds.\n\n[⍂ entity: {name}]\n"),
)
}
#[test]
fn apply_receipt_carries_authoritative_moved_files_and_manifest() {
let s = scratch();
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
write_claim(&s.medulla, "keepdoc.light.md", &doctrine("KeepDoc"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert_eq!(
receipt.moved_files,
vec!["alpha.light.md".to_string()],
"receipt must name exactly the moved files"
);
let manifest = PathBuf::from(&receipt.backup_dir).join("manifest.json");
assert!(manifest.is_file(), "backup dir carries a manifest.json");
let names: Vec<String> =
serde_json::from_str(&std::fs::read_to_string(&manifest).unwrap()).unwrap();
assert_eq!(names, vec!["alpha.light.md".to_string()]);
}
#[test]
fn rollback_with_moved_files_spares_preexisting_destination_claims() {
let s = scratch();
std::fs::create_dir_all(&s.project).unwrap();
write_claim(&s.project, "preexisting.light.md", &doctrine("Preexisting"));
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert!(
s.project.join("alpha.light.md").exists(),
"moved claim landed"
);
mig.rollback(&receipt.backup_dir, &receipt.moved_files)
.expect("rollback");
assert!(
s.project.join("preexisting.light.md").exists(),
"rollback must NOT delete a claim it never created"
);
assert!(
!s.project.join("alpha.light.md").exists(),
"rollback removed exactly the moved claim"
);
}
#[test]
fn apply_refuses_on_destination_name_collision() {
let s = scratch();
std::fs::create_dir_all(&s.project).unwrap();
let preexisting_bytes = doctrine("DestinationAlpha");
write_claim(&s.project, "alpha.light.md", &preexisting_bytes);
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let err = mig.apply().expect_err("collision must refuse");
let msg = err.to_string();
assert!(
msg.contains("alpha.light.md") && msg.to_ascii_lowercase().contains("collision"),
"refusal names the colliding file, got: {msg}"
);
assert_eq!(
std::fs::read_to_string(s.project.join("alpha.light.md")).unwrap(),
preexisting_bytes,
"pre-existing destination claim untouched by a refused apply"
);
assert!(
s.medulla.join("alpha.light.md").exists(),
"source claim still present after a refused apply"
);
}
#[test]
fn apply_verifies_content_level_conservation() {
let s = scratch();
std::fs::create_dir_all(&s.project).unwrap();
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
write_claim(&s.medulla, "keepdoc.light.md", &doctrine("KeepDoc"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert!(receipt.count_conserved, "cardinality gate holds");
assert!(
receipt.content_conserved,
"content-level gate: every moved file present at dest + gone from source"
);
assert!(s.project.join("alpha.light.md").exists());
assert!(!s.medulla.join("alpha.light.md").exists());
}
#[test]
fn rollback_snapshots_live_state_before_restoring() {
let s = scratch();
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
write_claim(&s.medulla, "keepdoc.light.md", &doctrine("KeepDoc"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
mig.rollback(&receipt.backup_dir, &receipt.moved_files)
.expect("rollback");
let snap = PathBuf::from(&receipt.backup_dir).join("pre-rollback-live");
assert!(
snap.is_dir(),
"rollback snapshots the live medulla state before wiping it"
);
assert!(
snap.join("alpha.light.md").exists() || snap.join("keepdoc.light.md").exists(),
"the pre-rollback snapshot captured the live claims"
);
}
#[test]
fn rollback_restores_ingest_roots_json() {
let s = scratch();
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
let dangling = s.medulla.join("gone.light.md");
let original_roots = serde_json::to_string_pretty(&vec![
s.medulla.to_string_lossy().to_string(),
dangling.to_string_lossy().to_string(),
])
.unwrap();
std::fs::write(&s.roots, &original_roots).unwrap();
let mig = scratch_mig(&s);
let receipt = mig.apply().expect("apply");
assert_ne!(
std::fs::read_to_string(&s.roots).unwrap(),
original_roots,
"apply pruned the ghost pointer from ingest_roots.json"
);
mig.rollback(&receipt.backup_dir, &receipt.moved_files)
.expect("rollback");
assert_eq!(
std::fs::read_to_string(&s.roots).unwrap(),
original_roots,
"rollback restores the pre-migration ingest_roots.json byte-for-byte"
);
}
#[test]
fn apply_and_rollback_refuse_while_owner_listener_is_up() {
use std::net::TcpListener;
let s = scratch();
write_claim(&s.medulla, "alpha.light.md", &repo_fact("Alpha"));
std::fs::write(
&s.roots,
serde_json::to_string_pretty(&vec![s.medulla.to_string_lossy().to_string()]).unwrap(),
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stand-in owner");
let port = listener.local_addr().unwrap().port();
let mig = MedullaMigration::new(&s.medulla, &s.project, &s.roots, "/path/to/repo")
.with_owner_guard_port(port);
let err = mig
.apply()
.expect_err("apply must refuse while a listener is up");
assert!(
err.to_string()
.to_ascii_lowercase()
.contains("stop the served owner"),
"refusal tells the maintainer to stop the served owner, got: {err}"
);
let err = mig
.rollback("unused", &[])
.expect_err("rollback must refuse while a listener is up");
assert!(
err.to_string()
.to_ascii_lowercase()
.contains("stop the served owner"),
"rollback refusal is the same guard, got: {err}"
);
}
}