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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-08 18:59:55 +01:00
commit 3114e011e4
2 changed files with 42 additions and 0 deletions

View file

@ -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() {

View file

@ -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());
}