huginn-mind: a campaign cannot be moved out of this mind's own instance root

A campaign slugged like the mind shares a root with the instance, its stewardships, memories and thoughts, so its Moved froze and closed all of them. A8 refuses such a Moved (MoveOfInstanceRoot); an import of the same campaign is refused already, the instance's records being native writes under its root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-08 19:12:13 +01:00
commit 0d4cccc157
4 changed files with 53 additions and 0 deletions

View file

@ -720,6 +720,11 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo
resolution_rule(docs, resolution)?;
match &resolution.outcome {
ResolutionOutcome::Moved { to } if to == mind => Err(MindRefusal::MoveToSelf),
// The campaign's root and the mind's own instance root are one namespace: the freeze would
// close the instance's memories, thoughts and stewardships with the campaign.
ResolutionOutcome::Moved { .. } if root_and_local(&resolution.subject.id.0).0 == mind.0 => {
Err(MindRefusal::MoveOfInstanceRoot { root: mind.0.clone() })
}
// The giver's resolution: the receiver replays the history the move froze, never the move.
ResolutionOutcome::Moved { .. } if import.is_some() => Err(MindRefusal::IncompatibleResolution {
subject_kind: resolution.subject.kind,

View file

@ -614,3 +614,28 @@ fn an_import_naming_a_case_variant_of_this_mind_names_another_mind() {
MindRefusal::ImportFromSelf
);
}
#[test]
fn a_campaign_cannot_be_moved_out_of_its_minds_own_instance_root() {
// A campaign slugged like this mind shares a root with the instance, its stewardships and its
// thoughts: the Moved would freeze all of them.
let mut mind = seeded();
let D::Campaign(mut named_for_the_mind) = campaign(&[REPO]) else { unreachable!() };
named_for_the_mind.slug = slug(INSTANCE);
committed(admit(&mut mind, vec![D::Campaign(named_for_the_mind)]));
let before = head(&mind);
let moving = resolution(r(K::Campaign, &format!("{INSTANCE}:campaign:self")), ResolutionOutcome::Moved { to: slug(RECEIVER) });
assert_eq!(refusal(admit(&mut mind, vec![moving])), MindRefusal::MoveOfInstanceRoot { root: INSTANCE.into() });
assert_eq!(head(&mind), before);
committed(admit(&mut mind, vec![thought("T1", "still writable", &[])]));
assert!(matches!(stewardship_status(&mind, INSTANCE), PipelineStatus::InForce));
// The same campaign cannot arrive by import either: the instance's records are native writes under its root.
let mut recv = receiver();
let D::Campaign(mut named_for_recv) = campaign(&[REPO]) else { unreachable!() };
named_for_recv.slug = slug(RECEIVER);
assert_eq!(
refusal(import_from_kiko(&mut recv, vec![D::Campaign(named_for_recv)])),
MindRefusal::ImportOutsideCampaign { root: RECEIVER.into() }
);
}

View file

@ -68,6 +68,8 @@ pub enum MindRefusal {
ImportFromSelf,
/// An import carries a document of `root`, which is no campaign this mind holds or the batch carries, or which this mind holds a native write under.
ImportOutsideCampaign { root: String },
/// A move names `root`, this mind's own instance: an instance's records are not a campaign's to freeze.
MoveOfInstanceRoot { root: String },
/// An import lists `id` as external, but this mind (or the batch) holds it.
ExternalPresent { id: String },
/// The persona document fails the published schema: `path` is the

View file

@ -1697,6 +1697,27 @@
"ImportOutsideCampaign"
]
},
{
"description": "A move names `root`, this mind's own instance: an instance's records are not a campaign's to freeze.",
"type": "object",
"properties": {
"MoveOfInstanceRoot": {
"type": "object",
"properties": {
"root": {
"type": "string"
}
},
"required": [
"root"
]
}
},
"additionalProperties": false,
"required": [
"MoveOfInstanceRoot"
]
},
{
"description": "An import lists `id` as external, but this mind (or the batch) holds it.",
"type": "object",