From 3114e011e4abcac02e67be95dc00767d255b07f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 18:59:55 +0100 Subject: [PATCH] huginn-mind: a Moved resolution lands alone under its root A sibling written in the Moved's batch froze with the campaign and never reached the receiver, whose replay stops before the Moved. A8 now refuses any staged document, derived ones included, rooted at a campaign whose Moved is in the same batch (CampaignMoved), on the giver and on import alike. Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 14 ++++++++++++++ crates/huginn-mind/src/move_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) 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()); +}