diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index ad37a64..9e73a99 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -528,6 +528,20 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo if let Some((moved, PipelineResolution { outcome: ResolutionOutcome::Moved { to }, .. })) = docs.moved_campaign(root_and_local(key).0) { return Err(MindRefusal::CampaignMoved { campaign: root_and_local(moved).0.into(), to: to.0.clone() }); } + // A Moved lands alone under its root: a sibling beside it would freeze with the campaign and + // never reach the receiver, whose replay stops before the Moved. + let root = root_and_local(key).0; + let alongside = docs.batch.iter().find_map(|other| match &other.document { + D::Resolution(PipelineResolution { outcome: ResolutionOutcome::Moved { to }, .. }) + if other.key != staged.key && root_and_local(&other.key).0 == root => + { + Some(to) + } + _ => None, + }); + if let Some(to) = alongside { + return Err(MindRefusal::CampaignMoved { campaign: root.into(), to: to.0.clone() }); + } match &staged.document { D::Campaign(campaign) => { if campaign.repos.is_empty() { diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 5037e22..a14fbce 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -491,3 +491,31 @@ fn an_imports_source_is_bounded_like_the_rest_of_provenance() { let exact = with(&|source| source.external = (0..IMPORT_EXTERNAL_MAX).map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect()); committed(import_as(&mut mind, exact, documents)); } + +#[test] +fn a_moved_resolution_lands_alone_under_its_root() { + let frozen = |outcome: PipelineAdmissionOutcome| { + assert_eq!(refusal(outcome), MindRefusal::CampaignMoved { campaign: CAMPAIGN.into(), to: RECEIVER.into() }); + }; + let mut mind = seeded(); + let head_before = head(&mind); + let sibling = || follow_up("FU-C", campaign_ref()); + // The giver refuses a Moved with a sibling under its root, whichever order they come in. + frozen(admit(&mut mind, vec![moved_to(RECEIVER), sibling()])); + frozen(admit(&mut mind, vec![sibling(), moved_to(RECEIVER)])); + assert_eq!(head(&mind), head_before, "nothing landed"); + // A second Moved is a sibling of the first. + let second = resolution_n(campaign_ref(), 2, ResolutionOutcome::Moved { to: slug(RECEIVER) }); + frozen(admit(&mut mind, vec![moved_to(RECEIVER), second])); + // A document of another root rides beside it. + committed(admit(&mut mind, vec![moved_to(RECEIVER), other_question("QO")])); + + // An import refuses the pairing too: its own sibling never lands beside a Moved. + let mut recv = receiver(); + committed(import(&mut recv, vec![campaign(&[REPO])], vec![])); + for documents in [vec![moved_to("kiko"), sibling()], vec![sibling(), moved_to("kiko")]] { + let refused = refusal(import(&mut recv, documents, vec![])); + assert!(matches!(refused, MindRefusal::CampaignMoved { .. } | MindRefusal::IncompatibleResolution { .. }), "{refused:?}"); + } + assert!(recv.view(&r(K::FollowUp, &id("follow_up", "FU-C"))).unwrap().is_none()); +}