From 0d4cccc1577a6a17d97ed7224a8fc6d1873f9fab Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 19:12:13 +0100 Subject: [PATCH] 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 --- crates/huginn-mind/src/admission.rs | 5 ++++ crates/huginn-mind/src/move_tests.rs | 25 +++++++++++++++++++ crates/huginn-mind/src/refusal.rs | 2 ++ .../huginn.mind_response.v1.schema.json | 21 ++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index f57b29e..a316b34 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -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, diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 48292fb..0b517c3 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -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() } + ); +} diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 648474f..0822eb4 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -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 diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index b83bf43..46d5a82 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -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",