From 0f417143f709db910cd967327a779c78b4d17884 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 8 Nov 2026 21:28:25 +0000 Subject: [PATCH 1/3] import: a follow-up or idea the receiver wrote after the import began no longer blocks the resume Nibu's stopped import of aetheria-release was refused twice over for her own run's follow-up chat-s4606-4606: by the verb's resume (a record under the root not imported from the source) and, had that passed, by admission (ImportOutsideCampaign, a native write under the root). Both now leave a native FollowUp or Idea alone when it was admitted after the root's first imported document, share one predicate (`inert_in_window`), and the verb names what it left in place and leaves it out of the final verification walk. Every other native kind (a question, a resolution, a ruling, a target) can answer or resolve a replayed document and still refuses; a collision on a planned key still refuses. No evict verb: a receipt cannot be unwritten. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/import.rs | 108 +++++++++++++++++-- crates/eureka-state/tests/cli.rs | 49 +++++++++ crates/huginn-mind/src/image.rs | 16 +-- crates/huginn-mind/src/import.rs | 9 ++ crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/move_tests.rs | 31 ++++++ 6 files changed, 201 insertions(+), 14 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/import.rs b/crates/eureka-state/src/bin/huginn/import.rs index 065688d..ae334c8 100644 --- a/crates/eureka-state/src/bin/huginn/import.rs +++ b/crates/eureka-state/src/bin/huginn/import.rs @@ -12,10 +12,10 @@ use std::collections::{BTreeMap, BTreeSet}; use cultnet_rs::{LIMIT_MAX, PROJECTION_DOCUMENT, Selection}; use eureka_state::hold; -use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineRef, Short, Slug}; +use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, Short, Slug}; use huginn_mind::{ - Faculty, HuginnMindRequest, HuginnMindResponse, ImportBatch, ImportFault, ImportPlan, PipelineAdmissionBatch, PipelineAdmissionOutcome, - PipelineDocumentSummary, PipelineDocumentView, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, plan, + AdmissionFacts, Faculty, HuginnMindRequest, HuginnMindResponse, ImportBatch, ImportFault, ImportPlan, PipelineAdmissionBatch, PipelineAdmissionOutcome, + PipelineDocumentSummary, PipelineDocumentView, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, inert_in_window, plan, }; use crate::config::{Settings, TOOL}; @@ -71,7 +71,12 @@ fn import(settings: &Settings, flags: Flags) -> Step<()> { let ask_receiver = |selection: Selection| { settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic: None }) }; - let held = resume(&plan, &headers(&ask_receiver, &campaign)?, giver.instance())?; + let root = headers(&ask_receiver, &campaign)?; + let held = resume(&plan, &root, giver.instance())?; + let window = window_writes(root.iter().map(|header| (header.id.kind, header.id.id.0.as_str(), &header.admission)), &planned_keys(&plan), giver.instance()); + if !window.is_empty() { + say(format_args!("left in place, admitted by the receiver after the import began: {}", window.iter().copied().collect::>().join(", "))); + } let (receipts, total) = (plan.batches.len(), plan.batches.iter().map(|batch| batch.documents.len()).sum::()); say(format_args!( "plan {campaign} from {giver}: {receipts} receipts {total} documents, {held} held, moved by {moved}", @@ -198,9 +203,27 @@ fn headers(ask: &dyn Fn(Selection) -> Result, campa Ok(headers) } +/// The receiver's own records under the root that were admitted after the first receipt imported +/// from this source and are no planned document: writes in the import window (ruling +/// `import-window-writes-ruled`: the import stops at a collision, and only a collision). They are +/// left where they are; they cannot be a planned document's key, and the admission of each later +/// batch still refuses any replay that meets one. +fn window_writes<'a>(root: impl Iterator + Clone, planned: &BTreeSet<&str>, source: &Slug) -> BTreeSet<&'a str> { + let began = root.clone().filter(|(_, _, admission)| admission.provenance.imported_from.as_ref().is_some_and(|from| from.instance == *source)).map(|(_, _, admission)| admission.ordinal).min(); + root.filter(|(kind, key, admission)| inert_in_window(*kind) && admission.provenance.imported_from.is_none() && !planned.contains(key) && began.is_some_and(|began| admission.ordinal > began)) + .map(|(_, key, _)| key) + .collect() +} + +/// The plan's document keys. +fn planned_keys(plan: &ImportPlan) -> BTreeSet<&str> { + plan.batches.iter().flat_map(|batch| batch.documents.iter().map(|(id, _)| id.id.0.as_str())).collect() +} + /// Which batches the receiver already holds whole. A document under the root that is not one of /// the plan's, imported from the plan's receipt of the source, is her own record and refuses the -/// import; a batch held in part cannot be resumed, only repaired. +/// import, unless she admitted it after the import began (`window_writes`); a batch held in part +/// cannot be resumed, only repaired. fn resume(plan: &ImportPlan, root: &[PipelineDocumentSummary], source: &Slug) -> Step> { let planned: BTreeMap<&str, (usize, &str)> = plan .batches @@ -210,8 +233,12 @@ fn resume(plan: &ImportPlan, root: &[PipelineDocumentSummary], source: &Slug) -> .collect(); let mut held: Vec> = vec![BTreeSet::new(); plan.batches.len()]; let mut own = Vec::new(); + let window = window_writes(root.iter().map(|header| (header.id.kind, header.id.id.0.as_str(), &header.admission)), &planned_keys(plan), source); for header in root { let key = header.id.id.0.as_str(); + if window.contains(key) { + continue; + } let imported = header.admission.provenance.imported_from.as_ref(); match planned.get(key) { Some((index, receipt)) if imported.is_some_and(|from| from.instance == *source && from.receipt_id.0 == *receipt) => { @@ -261,8 +288,9 @@ fn verify(root: &[PipelineDocumentView], plan: &ImportPlan, source: &Slug) -> St } } } - let planned: BTreeSet<&str> = plan.batches.iter().flat_map(|batch| batch.documents.iter().map(|(id, _)| id.id.0.as_str())).collect(); - differing.extend(held.keys().filter(|key| !planned.contains(*key)).map(|key| format!("{key}: not in the plan"))); + let planned = planned_keys(plan); + let window = window_writes(root.iter().map(|view| (view.id.kind, view.id.id.0.as_str(), &view.admission)), &planned, source); + differing.extend(held.keys().filter(|key| !planned.contains(*key) && !window.contains(*key)).map(|key| format!("{key}: not in the plan"))); if differing.is_empty() { Ok(()) } else { @@ -334,6 +362,16 @@ mod tests { } } + fn at(mut held: PipelineDocumentSummary, ordinal: u64) -> PipelineDocumentSummary { + held.admission.ordinal = ordinal; + held + } + + fn note(mut held: PipelineDocumentSummary, kind: PipelineKind) -> PipelineDocumentSummary { + held.id.kind = kind; + held + } + fn two() -> ImportPlan { plan_of(vec![batch(3, &["a", "b"]), batch(5, &["c"])]) } @@ -377,6 +415,62 @@ mod tests { } } + /// Nibu's case: her import stopped after its first receipt, her own run then admitted a + /// follow-up under the root, and the rerun was refused for it. + #[test] + fn a_record_the_receiver_admitted_after_the_import_began_does_not_block_the_resume() { + let source = slug("eureka"); + let root = [at(header("a", Some(("eureka", 3))), 10), at(header("b", Some(("eureka", 3))), 10), at(note(header("zz", None), PipelineKind::FollowUp), 20)]; + assert_eq!(resume(&two(), &root, &source).ok().unwrap(), vec![true, false]); + let window = window_writes(root.iter().map(|held| (held.id.kind, held.id.id.0.as_str(), &held.admission)), &planned_keys(&two()), &source); + assert_eq!(window.into_iter().collect::>(), vec!["c:ruling:zz"]); + } + + #[test] + fn only_a_native_unplanned_record_after_the_import_began_is_left_in_place() { + let source = slug("eureka"); + let began = [at(header("a", Some(("eureka", 3))), 10), at(header("b", Some(("eureka", 3))), 12)]; + for refused in [ + // admitted before the first imported receipt, however many came after + at(note(header("zz", None), PipelineKind::FollowUp), 9), + // a kind that could answer or resolve a planned document + at(header("zz", None), 20), + at(note(header("zz", None), PipelineKind::Resolution), 20), + // a planned key the receiver took first + at(note(header("c", None), PipelineKind::FollowUp), 20), + // another source's import, or this source's receipt for a document the plan lacks + at(note(header("zz", Some(("kiko", 3))), PipelineKind::FollowUp), 20), + at(note(header("zz", Some(("eureka", 9))), PipelineKind::FollowUp), 20), + ] { + let said = stopped(resume(&two(), &[began.as_slice(), &[refused]].concat(), &source)); + assert!(said.starts_with("no: Refused the receiver already holds records under the root"), "{said}"); + } + // the import is dated by its first receipt: a record between two imported ones is in the window + let between = [began.as_slice(), &[at(note(header("zz", None), PipelineKind::Idea), 11)]].concat(); + assert_eq!(resume(&two(), &between, &source).ok().unwrap(), vec![true, false]); + } + + #[test] + fn the_verification_walk_leaves_a_window_write_out_and_still_names_an_earlier_stranger() { + let source = slug("eureka"); + let plan = plan_of(vec![batch(3, &["a", "b"]), batch(5, &["c"])]); + let ours = |local: &str, ordinal, at: u64| { + let mut view = viewed(local, question(local), Some(("eureka", ordinal))); + view.admission.ordinal = at; + view + }; + let native = |local: &str, at: u64| { + let mut view = viewed(local, question(local), None); + view.id.kind = PipelineKind::FollowUp; + view.admission.ordinal = at; + view + }; + let whole = vec![ours("a", 3, 10), ours("b", 3, 10), ours("c", 5, 11)]; + assert!(verify(&[whole.as_slice(), &[native("zz", 20)]].concat(), &plan, &source).is_ok()); + let Err(Stop::Trouble(trouble)) = verify(&[whole.as_slice(), &[native("zz", 4)]].concat(), &plan, &source) else { panic!("a record before the import is not the window's") }; + assert!(trouble.to_string().contains("c:ruling:zz: not in the plan"), "{trouble}"); + } + fn viewed(local: &str, document: PipelineDocument, from: Option<(&str, u64)>) -> PipelineDocumentView { let held = header(local, from); PipelineDocumentView { id: held.id, document, admission: held.admission, status: PipelineStatus::InForce } diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index de4b2e0..0156131 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -1490,6 +1490,20 @@ fn question_of(campaign: &str, label: &str, title: &str) -> PipelineDocument { }) } +fn idea_of(campaign: &str, label: &str) -> PipelineDocument { + PipelineDocument::Idea(huginn_mind::eureka_pipeline::PipelineIdea { + campaign: slug(campaign), + pass: label.into(), + label: label.into(), + title: huginn_mind::eureka_pipeline::Title(label.into()), + idea: "A note.".into(), + tradeoffs: "".into(), + rests_on: vec![], + toward: vec![], + proposed_on: Date("2026-10-10".into()), + }) +} + fn for_lines(page: &str) -> Vec<&str> { page.lines().filter(|line| line.starts_with("For ")).collect() } @@ -2626,6 +2640,41 @@ fn import_resumes_after_a_stop() { assert!(imported_lines(&again.out).is_empty() && again.out.lines().next().unwrap().contains(&format!(", {} held,", plan.batches.len())), "{}", again.out); } +#[test] +fn import_resumes_past_a_record_the_receiver_admitted_after_it_began() { + let (root, mut giver_daemon) = giver_history(Some(TAKER)); + let plan = plan_from(&mut giver_daemon); + let giver = serve(root, giver_daemon); + let (root, mut daemon) = taker_mind(false); + half_imported(&mut daemon, &plan, 2, 0); + let own = answered(daemon.handle(HuginnMindRequest::Admit(batch(TAKER, vec![idea_of(CAMPAIGN, "Mine")])), chrono::Utc::now())); + assert!(matches!(own, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. }))); + let taker = serve(root, daemon); + + let ran = importing(&taker, INSTANCE, giver.addr); + assert_eq!(ran.code, 0, "{}{}", ran.out, ran.err); + assert!(ran.out.contains(&format!("left in place, admitted by the receiver after the import began: {CAMPAIGN}:idea:Mine.Mine")), "{}", ran.out); + assert_eq!(imported_lines(&ran.out).len(), plan.batches.len() - 2, "{}", ran.out); + assert!(ran.out.trim_end().ends_with(", verified"), "{}", ran.out); +} + +#[test] +fn import_still_refuses_a_native_question_admitted_after_it_began() { + let (root, mut giver_daemon) = giver_history(Some(TAKER)); + let plan = plan_from(&mut giver_daemon); + let giver = serve(root, giver_daemon); + let (root, mut daemon) = taker_mind(false); + half_imported(&mut daemon, &plan, 2, 0); + let own = answered(daemon.handle(HuginnMindRequest::Admit(batch(TAKER, vec![question_of(CAMPAIGN, "Mine", "Hers")])), chrono::Utc::now())); + assert!(matches!(own, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. }))); + let taker = serve(root, daemon); + + let ran = importing(&taker, INSTANCE, giver.addr); + assert_eq!(ran.code, 1, "{}{}", ran.out, ran.err); + assert!(ran.out.contains(&format!("{CAMPAIGN}:question:Mine")), "{}", ran.out); + assert!(imported_lines(&ran.out).is_empty(), "nothing is sent past a question the receiver wrote: {}", ran.out); +} + #[test] fn import_refuses_a_receipt_the_receiver_holds_only_part_of() { let (root, mut giver_daemon) = giver_history(Some(TAKER)); diff --git a/crates/huginn-mind/src/image.rs b/crates/huginn-mind/src/image.rs index 6f0b2ae..2f0ba7a 100644 --- a/crates/huginn-mind/src/image.rs +++ b/crates/huginn-mind/src/image.rs @@ -143,13 +143,17 @@ impl Image { self.closers.get(&kind).and_then(|named| named.get(id)).into_iter().flat_map(|keyed| keyed.iter().map(|(key, id)| (key.as_str(), *id))) } - /// Whether any document under `root` was admitted natively rather than imported. + /// Whether this mind holds a native write under `root` that an import must not meet: one admitted + /// before the first imported document of the root, or after it and not `inert_in_window`. pub(crate) fn root_holds_native_write(&self, root: &str) -> bool { - PipelineKind::ALL.iter().any(|kind| { - self.posting(*kind, Alias::Root.name(), root) - .into_iter() - .flatten() - .any(|id| self.row(*id).view.admission.provenance.imported_from.is_none()) + let rows: Vec<(PipelineKind, &SelectionRow)> = PipelineKind::ALL + .iter() + .flat_map(|kind| self.posting(*kind, Alias::Root.name(), root).into_iter().flatten().map(move |id| (*kind, self.row(*id)))) + .collect(); + let began = rows.iter().filter(|(_, row)| row.view.admission.provenance.imported_from.is_some()).map(|(_, row)| row.view.admission.ordinal).min(); + rows.iter().any(|(kind, row)| { + row.view.admission.provenance.imported_from.is_none() + && !(crate::import::inert_in_window(*kind) && began.is_some_and(|began| row.view.admission.ordinal > began)) }) } diff --git a/crates/huginn-mind/src/import.rs b/crates/huginn-mind/src/import.rs index 52b5335..19f9559 100644 --- a/crates/huginn-mind/src/import.rs +++ b/crates/huginn-mind/src/import.rs @@ -51,6 +51,15 @@ pub enum ImportFault { Unordered { ordinal: u64, id: Short }, } +/// Whether a native write of this kind, admitted after an import under its root began, cannot touch +/// the status or derivation of any document the import will still replay: a follow-up or an idea +/// has no referent a replay resolves and derives nothing. Ruling `import-window-writes-ruled` +/// stops an import at a collision; this is the writes that cannot collide. Every other kind may +/// answer, supersede or resolve a planned document, and still refuses the import. +pub fn inert_in_window(kind: PipelineKind) -> bool { + matches!(kind, PipelineKind::FollowUp | PipelineKind::Idea) +} + /// The plan for `receiver` to import `campaign` from `source`, given every document the source /// holds under the campaign's root. pub fn plan(campaign: &Slug, source: &Slug, receiver: &Slug, views: Vec) -> Result { diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 47837d4..337778d 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -55,7 +55,7 @@ mod move_tests; pub use docs::CitationRole; pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text}; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; -pub use import::{ImportBatch, ImportFault, ImportPlan, plan}; +pub use import::{ImportBatch, ImportFault, ImportPlan, inert_in_window, plan}; pub use mind::{HuginnMindEpoch, Mind}; pub use persona::{PersonaPutOutcome, PersonaStateView}; pub use query::{ diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 37ef456..abcc64e 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -582,6 +582,37 @@ fn an_import_lands_only_under_a_root_that_only_imports_have_written() { ); } +#[test] +fn an_import_resumes_past_a_follow_up_or_idea_the_receiver_wrote_after_it_began() { + let q1 = id("question", "Q1"); + let begun = || { + let mut recv = receiver(); + committed(import_from_kiko(&mut recv, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A")])); + recv + }; + for native in [follow_up("FU-N", r(K::Question, &q1)), idea("p", "note", "a note")] { + let mut recv = begun(); + committed(admit(&mut recv, vec![native])); + committed(import_from_kiko(&mut recv, vec![question("Q2", &["A", "B"], "A")])); + committed(import_from_kiko(&mut recv, vec![answering(CAMPAIGN, "R1", &q1)])); + } + + // Before the first imported document the same record is the receiver's own, as ever. + let mut early = receiver(); + committed(admit(&mut early, vec![idea("p", "note", "a note")])); + assert_eq!( + refusal(import_from_kiko(&mut early, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A")])), + MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } + ); + + // A kind that could answer, supersede or resolve a replayed document still refuses, after the import began. + for native in [question("Q9", &["A", "B"], "A"), resolution(r(K::Question, &q1), ResolutionOutcome::Withdrawn { reason: "native".into() })] { + let mut recv = begun(); + committed(admit(&mut recv, vec![native])); + assert_eq!(refusal(import_from_kiko(&mut recv, vec![question("Q2", &["A", "B"], "A")])), MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() }); + } +} + #[test] fn a_replay_under_another_source_is_no_replay() { let mut recv = receiver(); -- 2.47.3 From 9958555d719ffa916e91a1a9bc885447891dc355 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 8 Nov 2026 21:43:00 +0000 Subject: [PATCH 2/3] import: an imported record never cites or resolves the receiver's own native document Soul F1: the window left open by `inert_in_window` let a later imported record withdraw or cite the receiver's native follow-up. Admission now refuses an imported document that cites or resolves a native document of its own root (another root's native documents are cited as ever). Stale docs and the import help name the amended rule. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 2 +- crates/huginn-mind/src/admission.rs | 8 +++++++- crates/huginn-mind/src/docs.rs | 7 ++++++- crates/huginn-mind/src/move_tests.rs | 24 ++++++++++++++++++++++ crates/huginn-mind/src/refusal.rs | 2 +- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index 046faa9..f63599c 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -62,7 +62,7 @@ The file is a JSON array of 1 to 64 documents, each {\"kind\": , \"value\" Every key the schema does not know is refused (exit 2) with its path; nothing is sent. \ After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back equal`, or exit 5 with the ids and field paths that differ. A batch whose rulings or questions have in-force neighbours is held once: exit 6, the neighbours printed with a token, nothing sent. Run the same command with --seen to admit it; a batch or --agent that changed needs a new token (a wrong one is exit 2)."; -const IMPORT_HELP: &str = "Reads the giver's mind (it sends Query alone) and admits its receipts for the campaign into this one, one batch per source receipt, in order, each naming the giver's receipt in imported_from. It refuses unless the campaign is moved to this mind (exit 1), and when this mind already holds records under the campaign that it did not import from the giver (exit 1). A rerun after any stop resumes at the first batch this mind does not hold; it prints `imported from : ... verified` only after comparing every document it holds with the giver's (exit 5 if one differs). Until it prints imported, this mind writes nothing else under the campaign; a write that collides stops the import with the refusal (exit 1). Exit 3 Unavailable (rerun), 4 the history cannot be replayed whole (a receipt over 64 documents, too many external referents, a receipt held in part). Only a Self run can run it: a Persona's traffic to a peer's huginn port is fenced."; +const IMPORT_HELP: &str = "Reads the giver's mind (it sends Query alone) and admits its receipts for the campaign into this one, one batch per source receipt, in order, each naming the giver's receipt in imported_from. It refuses unless the campaign is moved to this mind (exit 1), and when this mind already holds records under the campaign that it did not import from the giver, except follow-ups and ideas it admitted after the import began, which stay in place and are named (exit 1). A rerun after any stop resumes at the first batch this mind does not hold; it prints `imported from : ... verified` only after comparing every document it holds with the giver's (exit 5 if one differs). Until it prints imported, this mind writes nothing else under the campaign; a write that collides stops the import with the refusal (exit 1). Exit 3 Unavailable (rerun), 4 the history cannot be replayed whole (a receipt over 64 documents, too many external referents, a receipt held in part). Only a Self run can run it: a Persona's traffic to a peer's huginn port is fenced."; #[derive(Parser, Debug)] #[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, persona, admit, import, recall, schema", arg_required_else_help = true)] diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 40d3221..99dc13e 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -511,7 +511,8 @@ fn derive(docs: &Docs<'_>, mind: &Slug) -> Vec { } /// An import replays a campaign's history: every document is rooted at a campaign this mind holds -/// or the batch carries, and the mind holds no native write under that root, so the source's own +/// or the batch carries, and the mind holds no native write under that root but a follow-up or idea +/// admitted after the import began (`inert_in_window`) that no imported document cites or resolves, so the source's own /// derivations stand alone and no native rule is bypassed for a record the mind could have written. /// Instance-rooted records (hand-offs, stewardships, runs, memories, thoughts) are never history /// to replay. @@ -522,6 +523,11 @@ fn refuse_unscoped_import(docs: &Docs<'_>) -> Result<(), MindRefusal> { if docs.find(PipelineKind::Campaign, &campaign).is_none() || docs.root_holds_native_write(root) { return Err(MindRefusal::ImportOutsideCampaign { root: root.into() }); } + // The receiver's native follow-ups and ideas stand beside a replay; the replay never reaches them. + // Another root's native documents may be cited, as ever; only this root's are the receiver's own. + if citations(&staged.document).iter().any(|(_, referent)| root_and_local(&referent.id.0).0 == root && docs.is_native(referent.kind, &referent.id.0)) { + return Err(MindRefusal::ImportOutsideCampaign { root: root.into() }); + } } Ok(()) } diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index 7b5f856..afe1ae6 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -60,6 +60,11 @@ impl<'i> Docs<'i> { self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) } + /// Whether the mind holds this document as its own write rather than an import. + pub(crate) fn is_native(&self, kind: PipelineKind, id: &str) -> bool { + self.image.id_of(kind, id).filter(|row| self.visible(*row)).is_some_and(|row| self.image.row(row).view.admission.provenance.imported_from.is_none()) + } + pub(crate) fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| &self.image.row(row).view.document) } @@ -68,7 +73,7 @@ impl<'i> Docs<'i> { self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) } - /// Whether this mind holds a native write under `root` (the batch is not asked). + /// Whether this mind holds a native write under `root` that an import must not meet (the batch is not asked). pub(crate) fn root_holds_native_write(&self, root: &str) -> bool { self.image.root_holds_native_write(root) } diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 52a4430..216edb0 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -613,6 +613,30 @@ fn an_import_resumes_past_a_follow_up_or_idea_the_receiver_wrote_after_it_began( } } +#[test] +fn an_import_never_cites_or_resolves_a_follow_up_the_receiver_wrote() { + let q1 = id("question", "Q1"); + let mut recv = receiver(); + committed(import_from_kiko(&mut recv, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A")])); + let native = follow_up("FU-N", r(K::Question, &q1)); + let native_ref = r(K::FollowUp, &pipeline_key(&native).unwrap()); + committed(admit(&mut recv, vec![native])); + let before = head(&recv); + // An imported record that withdraws the receiver's own follow-up mixes a replay into her work. + assert_eq!( + refusal(import_from_kiko(&mut recv, vec![resolution(native_ref.clone(), ResolutionOutcome::Withdrawn { reason: "x".into() })])), + MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } + ); + // Nor may one cite it. + assert_eq!( + refusal(import_from_kiko(&mut recv, vec![follow_up("FU-I", native_ref.clone())])), + MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } + ); + assert_eq!(head(&recv), before); + // Her follow-up still closes natively. + committed(admit(&mut recv, vec![resolution(native_ref, ResolutionOutcome::Withdrawn { reason: "x".into() })])); +} + #[test] fn a_replay_under_another_source_is_no_replay() { let mut recv = receiver(); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 0822eb4..fd4b52d 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -66,7 +66,7 @@ pub enum MindRefusal { MoveIsFinal, /// An import names this mind as its source. 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. + /// An import carries a document of `root`, which is no campaign this mind holds or the batch carries, or under which this mind holds a native write that is not a follow-up or idea admitted after the import began, or which cites or resolves one of its own native documents. 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 }, -- 2.47.3 From d0edfdb403c31866d59c6b9189ecb5473e9767ed Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 8 Nov 2026 21:55:35 +0000 Subject: [PATCH 3/3] import: publish the amended ImportOutsideCampaign text; pin the outcome-citation path Soul: the schema carries the refusal's doc comment, so the published response schema follows it; an imported Superseded{by} naming the receiver's native follow-up is refused, which a source-only mutant now fails. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/move_tests.rs | 9 +++++++++ schemas/cultnet/huginn.mind_response.v1.schema.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 216edb0..f4e1c7c 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -632,6 +632,15 @@ fn an_import_never_cites_or_resolves_a_follow_up_the_receiver_wrote() { refusal(import_from_kiko(&mut recv, vec![follow_up("FU-I", native_ref.clone())])), MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } ); + // Nor may a closing of its own point at it: an imported follow-up superseded by her native one. + let own_fu = follow_up("FU-I2", r(K::Question, &q1)); + let own_fu_ref = r(K::FollowUp, &pipeline_key(&own_fu).unwrap()); + committed(import_from_kiko(&mut recv, vec![own_fu])); + let before = head(&recv); + assert_eq!( + refusal(import_from_kiko(&mut recv, vec![resolution(own_fu_ref, ResolutionOutcome::Superseded { by: vec![native_ref.clone()] })])), + MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } + ); assert_eq!(head(&recv), before); // Her follow-up still closes natively. committed(admit(&mut recv, vec![resolution(native_ref, ResolutionOutcome::Withdrawn { reason: "x".into() })])); diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 46d5a82..fcdac91 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -1677,7 +1677,7 @@ "const": "ImportFromSelf" }, { - "description": "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.", + "description": "An import carries a document of `root`, which is no campaign this mind holds or the batch carries, or under which this mind holds a native write that is not a follow-up or idea admitted after the import began, or which cites or resolves one of its own native documents.", "type": "object", "properties": { "ImportOutsideCampaign": { -- 2.47.3