From 905c293396e700692a56ebcd661d14dd0e4bb898 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 19:12:07 +0100 Subject: [PATCH] huginn-mind: replay detection compares provenance Whoever wrote a batch first decided its attribution: a native writer of a batch already imported was told AlreadyAdmitted and the record kept naming the source, and an import of a batch another source landed read the same. A9 now treats a stored receipt as a replay only when its imported_from equals the candidate's; otherwise the batch meets its own identities at A10 (IdentityCollision). A source naming this mind in another case is another mind, as the instance grammar already rules, and is pinned by a test. Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 6 +++++- crates/huginn-mind/src/move_tests.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index d0a9f13..f57b29e 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -183,7 +183,11 @@ impl Mind { check(&docs, staged, &mind, import.as_ref())?; } // A9 - if let Some(existing) = receipt::replay(self, &candidate)? { + // The same batch under another source (or none) is no replay: the first writer's attribution + // stands, so the later asker meets the identities it collides with at A10. + if let Some(existing) = receipt::replay(self, &candidate)? + && existing.provenance.imported_from == candidate.provenance.imported_from + { return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); } // A10 diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 7bc693a..48292fb 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -582,3 +582,35 @@ fn an_import_lands_only_under_a_root_that_only_imports_have_written() { MindRefusal::ImportOutsideCampaign { root: CAMPAIGN.into() } ); } + +#[test] +fn a_replay_under_another_source_is_no_replay() { + let mut recv = receiver(); + let history = vec![campaign(&[REPO]), question("Q7", &["A", "B"], "A")]; + let q7 = id("question", "Q7"); + committed(import_from_kiko(&mut recv, history.clone())); + let recorded = |mind: &Mind| view_of(mind, K::Question, &q7).admission.provenance.imported_from.map(|source| source.instance.0); + assert_eq!(recorded(&recv).as_deref(), Some("kiko")); + let before = head(&recv); + + // The native writer of the same batch is not told it was already admitted, and the record + // keeps naming the source that wrote it. + assert!(matches!(refusal(admit(&mut recv, history.clone())), MindRefusal::IdentityCollision { .. })); + // Nor does another source take the first one's place. + let other = import_as(&mut recv, source_of("nibu", vec![]), history); + assert!(matches!(refusal(other), MindRefusal::IdentityCollision { .. })); + assert_eq!(recorded(&recv).as_deref(), Some("kiko")); + assert_eq!(head(&recv), before); +} + +#[test] +fn an_import_naming_a_case_variant_of_this_mind_names_another_mind() { + // Two minds whose names differ only in case are two minds (fixtures::CASED_INSTANCE). + let mut mind = opened(MemoryStore::new(), INSTANCE); + committed(admit(&mut mind, vec![instance(INSTANCE)])); + committed(import_as(&mut mind, source_of(CASED_INSTANCE, vec![]), vec![campaign(&[REPO])])); + assert_eq!( + refusal(import_as(&mut mind, source_of(INSTANCE, vec![]), vec![question("Q1", &["A", "B"], "A")])), + MindRefusal::ImportFromSelf + ); +}