From 3b64a1aaa3e15daf724dc60c101d08797efb70fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 21:15:16 +0100 Subject: [PATCH 1/5] admission: an import is not refused for citing a document resolved after its source ordinal Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 21 +++--- crates/huginn-mind/src/move_tests.rs | 99 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index a316b34..462dee7 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -538,6 +538,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo use PipelineKind as K; let key = staged.key.as_str(); let external: &[PipelineRef] = import.map_or(&[], |source| source.external.as_slice()); + // An import replays a batch its source admitted at its own ordinal: a referent's status here may + // have moved on since (another campaign's import carried the resolution), so no rule judges it. + let historic = import.is_some(); if let D::Resolution(resolution) = &staged.document && matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. }) && resolution.subject.kind == K::Resolution @@ -627,7 +630,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); } for ruling in &spec.rulings { - if !docs.in_force(K::Ruling, &ruling.0) { + if !historic && !docs.in_force(K::Ruling, &ruling.0) { return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); } } @@ -653,7 +656,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo } return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); }; - if !docs.in_force(K::CutSpec, &report.cut_spec.0) { + if !historic && !docs.in_force(K::CutSpec, &report.cut_spec.0) { return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); } if report.repo != spec.repo { @@ -717,7 +720,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo Ok(()) } D::Resolution(resolution) => { - resolution_rule(docs, resolution)?; + resolution_rule(docs, resolution, historic)?; 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 @@ -762,7 +765,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo // rule. D::Run(run) => { let replay = docs.in_image(K::Run, key) == Some(&staged.document); - run_rule(docs, key, run, replay) + run_rule(docs, key, run, replay, historic) } D::Memory(memory) => consolidation_rule(docs, key, memory), D::FollowUp(_) | D::Instance(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Research(_) | D::Idea(_) => Ok(()), @@ -772,9 +775,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo /// The one rule for a run opening or reinstated by withdrawing its close: every /// claim in force (a replay of an admitted run owes none) and held by no other /// live run, then at most one live run of hers per instance and turn. -fn run_rule(docs: &Docs<'_>, run_key: &str, run: &PipelineRun, replay: bool) -> Result<(), MindRefusal> { +fn run_rule(docs: &Docs<'_>, run_key: &str, run: &PipelineRun, replay: bool, historic: bool) -> Result<(), MindRefusal> { for claim in &run.claims { - if !replay && !docs.in_force(claim.kind, &claim.id.0) { + if !replay && !historic && !docs.in_force(claim.kind, &claim.id.0) { return Err(MindRefusal::CitesResolvedDocument { kind: claim.kind, id: claim.id.0.clone() }); } if let Some(holder) = docs.claim_holder(run_key, claim) { @@ -907,7 +910,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { /// every referent in force, and the two /// coherence rules (an `Answered` ruling answers this question; a cut spec is /// superseded within its cut). -fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution) -> Result<(), MindRefusal> { +fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution, historic: bool) -> Result<(), MindRefusal> { use PipelineDocument as D; use PipelineKind as K; let subject_kind = resolution.subject.kind; @@ -973,11 +976,11 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution) -> Result<( && reinstating.subject.kind == K::Run && let Some(D::Run(run)) = docs.find(K::Run, &reinstating.subject.id.0) { - run_rule(docs, &reinstating.subject.id.0, run, false)?; + run_rule(docs, &reinstating.subject.id.0, run, false, historic)?; } for (role, referent) in outcome_citations(&resolution.outcome) { // The last memory a night was shown may have been consumed by that night's own batches. - if role != CitationRole::DayThrough && !docs.in_force(referent.kind, &referent.id.0) { + if role != CitationRole::DayThrough && !historic && !docs.in_force(referent.kind, &referent.id.0) { return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 }); } } diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 3bd200a..f857828 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -638,3 +638,102 @@ fn a_campaign_cannot_be_moved_out_of_its_minds_own_instance_root() { MindRefusal::ImportOutsideCampaign { root: RECEIVER.into() } ); } + +fn fu(root: &str, label: &str) -> PipelineRef { + r(K::FollowUp, &format!("{root}:follow_up:{label}")) +} + +/// The receiver after campaign A (`CAMPAIGN`) came in whole: its follow-up FU-1 is open there, and +/// FU-A is recorded. +fn receiver_holding_a_resolved_follow_up() -> Mind { + let mut mind = receiver(); + let q1 = r(K::Question, &id("question", "Q1")); + committed(import(&mut mind, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A"), follow_up("FU-A", q1)], vec![])); + committed(import(&mut mind, vec![resolution(fu(CAMPAIGN, "FU-A"), ResolutionOutcome::Recorded { reason: "noted".into() })], vec![])); + mind +} + +/// Campaign B's receipt, admitted by its giver while FU-A was still open: FU-B superseded by FU-A. +fn b_supersedes_a() -> Vec { + vec![resolution(fu(OTHER_ROOT, "FU-B"), ResolutionOutcome::Superseded { by: vec![fu(CAMPAIGN, "FU-A")] })] +} + +fn b_campaign() -> Vec { + vec![other_campaign(), other_question("Q9"), other_follow_up("FU-B", "Q9")] +} + +#[test] +fn an_import_may_cite_a_document_the_receiver_resolved_after_the_source_ordinal() { + let mut mind = receiver_holding_a_resolved_follow_up(); + committed(import(&mut mind, b_campaign(), vec![])); + let (_, writes) = committed(import(&mut mind, b_supersedes_a(), vec![])); + assert_eq!(writes.len(), 1); + let view = view_of(&mind, K::FollowUp, &fu(OTHER_ROOT, "FU-B").id.0); + assert!(matches!(view.status, PipelineStatus::Resolved { .. }), "{:?}", view.status); +} + +#[test] +fn a_native_admission_citing_a_resolved_document_is_still_refused() { + let mut mind = receiver(); + let q1 = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A"), follow_up("FU-A", q1.clone()), follow_up("FU-B", q1)])); + committed(admit(&mut mind, vec![resolution(fu(CAMPAIGN, "FU-A"), ResolutionOutcome::Recorded { reason: "noted".into() })])); + let superseded = resolution(fu(CAMPAIGN, "FU-B"), ResolutionOutcome::Superseded { by: vec![fu(CAMPAIGN, "FU-A")] }); + assert_eq!(refusal(admit(&mut mind, vec![superseded])), MindRefusal::CitesResolvedDocument { kind: K::FollowUp, id: fu(CAMPAIGN, "FU-A").id.0 }); +} + +#[test] +fn a_campaign_imported_after_the_one_it_cites_resumes_and_matches_its_giver() { + let (mut giver, _) = giver(); + committed(admit(&mut giver, b_campaign())); + // B's receipt cites FU-1 while it is open; A resolves it afterwards. + committed(admit(&mut giver, vec![resolution(fu(OTHER_ROOT, "FU-B"), ResolutionOutcome::Superseded { by: vec![fu(CAMPAIGN, "FU-1")] })])); + committed(admit(&mut giver, vec![resolution(fu(CAMPAIGN, "FU-1"), ResolutionOutcome::Recorded { reason: "noted".into() })])); + committed(admit(&mut giver, vec![moved_to(RECEIVER)])); + committed(admit(&mut giver, vec![resolution(r(K::Campaign, &format!("{OTHER_ROOT}:campaign:self")), ResolutionOutcome::Moved { to: slug(RECEIVER) })])); + + let mut mind = receiver(); + for outcome in replay_root(&giver, &mut mind, CAMPAIGN) { + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + let outcomes = replay_root(&giver, &mut mind, OTHER_ROOT); + assert!(outcomes.len() >= 3); + for outcome in &outcomes { + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + let key = fu(OTHER_ROOT, "FU-B").id.0; + assert_eq!(view_of(&mind, K::FollowUp, &key).status, Reader::at(&giver, head(&giver) - 2).unwrap().view(&r(K::FollowUp, &key)).unwrap().unwrap().status); + + // A run stopped partway runs again from the top: what landed answers AlreadyAdmitted. + for outcome in replay_root(&giver, &mut mind, OTHER_ROOT) { + assert!(matches!(outcome, PipelineAdmissionOutcome::AlreadyAdmitted { .. }), "{outcome:?}"); + } +} + +/// `replay` for any root, declaring external only what the receiver does not hold, as the verb does. +fn replay_root(giver: &Mind, receiver: &mut Mind, root: &str) -> Vec { + let mut selection = Selection { + fields: Some(vec![FieldPredicate { index: "root".into(), op: "any_of".into(), values: Some(vec![root.into()]), number: None }]), + projection: "document".into(), + limit: Some(cultnet_rs::LIMIT_MAX), + ..Selection::default() + }; + let mut views = Vec::new(); + loop { + let page = giver.query(&selection).unwrap(); + let PipelinePageItems::Documents(found) = page.items else { panic!("the document projection answers documents") }; + views.extend(found); + match page.next { + Some(next) => selection.cursor = Some(next), + None => break, + } + } + let plan = crate::import::plan(&slug(root), giver.instance(), receiver.instance(), views).unwrap(); + plan.batches + .into_iter() + .map(|mut batch| { + batch.source.external.retain(|referent| receiver.view(referent).unwrap().is_none()); + import_as(receiver, batch.source, batch.documents.into_iter().map(|(_, document)| document).collect()) + }) + .collect() +} -- 2.47.3 From 6beb52e809b5168dd0a90e5d67528be3298b2d32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 21:16:43 +0100 Subject: [PATCH 2/5] move_tests: B's two receipts Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/move_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index f857828..3e919bf 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -697,7 +697,7 @@ fn a_campaign_imported_after_the_one_it_cites_resumes_and_matches_its_giver() { assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); } let outcomes = replay_root(&giver, &mut mind, OTHER_ROOT); - assert!(outcomes.len() >= 3); + assert_eq!(outcomes.len(), 2, "B lands as its own opening and the citing receipt"); for outcome in &outcomes { assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); } -- 2.47.3 From f4f857a7da48e04b5cb3afe55165c094f6c99dc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 21:54:59 +0100 Subject: [PATCH 3/5] admission: skip the status check only for a citation into another campaign; drop the unreachable run-claims skip; a test for each live site Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 19 +++--- crates/huginn-mind/src/move_tests.rs | 92 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 462dee7..abdae17 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -538,8 +538,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo use PipelineKind as K; let key = staged.key.as_str(); let external: &[PipelineRef] = import.map_or(&[], |source| source.external.as_slice()); - // An import replays a batch its source admitted at its own ordinal: a referent's status here may - // have moved on since (another campaign's import carried the resolution), so no rule judges it. + // An import replays the giver's order within a campaign, so a same-campaign referent has its status at + // the source ordinal. Only a citation into another campaign drifts (that campaign's import may have + // carried a later resolution), so only a cross-campaign citation is not judged by current status. let historic = import.is_some(); if let D::Resolution(resolution) = &staged.document && matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. }) @@ -630,7 +631,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); } for ruling in &spec.rulings { - if !historic && !docs.in_force(K::Ruling, &ruling.0) { + if !(historic && root_and_local(&ruling.0).0 != root) && !docs.in_force(K::Ruling, &ruling.0) { return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); } } @@ -656,7 +657,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo } return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); }; - if !historic && !docs.in_force(K::CutSpec, &report.cut_spec.0) { + if !(historic && root_and_local(&report.cut_spec.0).0 != root) && !docs.in_force(K::CutSpec, &report.cut_spec.0) { return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); } if report.repo != spec.repo { @@ -765,7 +766,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo // rule. D::Run(run) => { let replay = docs.in_image(K::Run, key) == Some(&staged.document); - run_rule(docs, key, run, replay, historic) + run_rule(docs, key, run, replay) } D::Memory(memory) => consolidation_rule(docs, key, memory), D::FollowUp(_) | D::Instance(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Research(_) | D::Idea(_) => Ok(()), @@ -775,9 +776,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo /// The one rule for a run opening or reinstated by withdrawing its close: every /// claim in force (a replay of an admitted run owes none) and held by no other /// live run, then at most one live run of hers per instance and turn. -fn run_rule(docs: &Docs<'_>, run_key: &str, run: &PipelineRun, replay: bool, historic: bool) -> Result<(), MindRefusal> { +fn run_rule(docs: &Docs<'_>, run_key: &str, run: &PipelineRun, replay: bool) -> Result<(), MindRefusal> { for claim in &run.claims { - if !replay && !historic && !docs.in_force(claim.kind, &claim.id.0) { + if !replay && !docs.in_force(claim.kind, &claim.id.0) { return Err(MindRefusal::CitesResolvedDocument { kind: claim.kind, id: claim.id.0.clone() }); } if let Some(holder) = docs.claim_holder(run_key, claim) { @@ -976,11 +977,11 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution, historic: b && reinstating.subject.kind == K::Run && let Some(D::Run(run)) = docs.find(K::Run, &reinstating.subject.id.0) { - run_rule(docs, &reinstating.subject.id.0, run, false, historic)?; + run_rule(docs, &reinstating.subject.id.0, run, false)?; } for (role, referent) in outcome_citations(&resolution.outcome) { // The last memory a night was shown may have been consumed by that night's own batches. - if role != CitationRole::DayThrough && !historic && !docs.in_force(referent.kind, &referent.id.0) { + if role != CitationRole::DayThrough && !(historic && root_and_local(&referent.id.0).0 != root_and_local(subject_id).0) && !docs.in_force(referent.kind, &referent.id.0) { return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 }); } } diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index 3e919bf..b4cdcb3 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -737,3 +737,95 @@ fn replay_root(giver: &Mind, receiver: &mut Mind, root }) .collect() } + +fn ruling_of(root: &str, label: &str) -> D { + let mut value = ruling(label); + value.campaign = slug(root); + D::Ruling(value) +} + +fn ruling_ref(root: &str, label: &str) -> PipelineRef { + r(K::Ruling, &format!("{root}:ruling:{label}")) +} + +fn spec_ref(root: &str, revision: u32) -> PipelineRef { + r(K::CutSpec, &format!("{root}:cut_spec:cut-1.r{revision}")) +} + +fn spec_of(root: &str, revision: u32) -> D { + let mut value = cut_spec("1", revision); + value.campaign = slug(root); + D::CutSpec(value) +} + +fn report_citing(spec: &PipelineRef) -> D { + let mut value = cut_report("1", 1); + value.cut_spec = spec.id.clone(); + D::CutReport(value) +} + +fn spec_citing(ruling: &PipelineRef) -> D { + let mut value = cut_spec("2", 1); + value.rulings = vec![ruling.id.clone()]; + D::CutSpec(value) +} + +/// A receiver holding `root` whole, in which `old` is superseded by `new` (both of that root). +fn receiver_with_superseded(root: &str, opening: Vec, old: PipelineRef, new: PipelineRef) -> Mind { + let mut mind = receiver(); + let mut first = vec![if root == CAMPAIGN { campaign(&[REPO]) } else { other_campaign() }]; + first.extend(opening); + committed(import(&mut mind, first, vec![])); + committed(import(&mut mind, vec![resolution(old, superseded(&[new]))], vec![])); + mind +} + +#[test] +fn an_imported_report_may_cite_another_campaigns_spec_superseded_since() { + let (old, new) = (spec_ref(OTHER_ROOT, 1), spec_ref(OTHER_ROOT, 2)); + let mut mind = receiver_with_superseded(OTHER_ROOT, vec![spec_of(OTHER_ROOT, 1), spec_of(OTHER_ROOT, 2)], old.clone(), new); + committed(import(&mut mind, vec![campaign(&[REPO])], vec![])); + let (_, writes) = committed(import(&mut mind, vec![report_citing(&old)], vec![])); + assert_eq!(writes.len(), 1); +} + +#[test] +fn an_imported_report_citing_its_own_campaigns_superseded_spec_is_refused() { + let (old, new) = (spec_ref(CAMPAIGN, 1), spec_ref(CAMPAIGN, 2)); + let mut mind = receiver_with_superseded(CAMPAIGN, vec![spec_of(CAMPAIGN, 1), spec_of(CAMPAIGN, 2)], old.clone(), new); + assert_eq!( + refusal(import(&mut mind, vec![report_citing(&old)], vec![])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: old.id.0 } + ); +} + +#[test] +fn an_imported_spec_may_cite_another_campaigns_ruling_superseded_since() { + let (old, new) = (ruling_ref(OTHER_ROOT, "R1"), ruling_ref(OTHER_ROOT, "R2")); + let mut mind = receiver_with_superseded(OTHER_ROOT, vec![ruling_of(OTHER_ROOT, "R1"), ruling_of(OTHER_ROOT, "R2")], old.clone(), new); + committed(import(&mut mind, vec![campaign(&[REPO])], vec![])); + let (_, writes) = committed(import(&mut mind, vec![spec_citing(&old)], vec![])); + assert_eq!(writes.len(), 1); +} + +#[test] +fn an_imported_spec_citing_its_own_campaigns_superseded_ruling_is_refused() { + let (old, new) = (ruling_ref(CAMPAIGN, "R1"), ruling_ref(CAMPAIGN, "R2")); + let mut mind = receiver_with_superseded(CAMPAIGN, vec![ruling_of(CAMPAIGN, "R1"), ruling_of(CAMPAIGN, "R2")], old.clone(), new); + assert_eq!( + refusal(import(&mut mind, vec![spec_citing(&old)], vec![])), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: old.id.0 } + ); +} + +#[test] +fn an_imported_follow_up_may_not_be_superseded_by_its_own_campaigns_closed_follow_up() { + let q1 = r(K::Question, &id("question", "Q1")); + let mut mind = receiver(); + committed(import(&mut mind, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A"), follow_up("FU-A", q1.clone()), follow_up("FU-B", q1)], vec![])); + committed(import(&mut mind, vec![resolution(fu(CAMPAIGN, "FU-A"), ResolutionOutcome::Recorded { reason: "noted".into() })], vec![])); + assert_eq!( + refusal(import(&mut mind, vec![resolution(fu(CAMPAIGN, "FU-B"), superseded(&[fu(CAMPAIGN, "FU-A")]))], vec![])), + MindRefusal::CitesResolvedDocument { kind: K::FollowUp, id: fu(CAMPAIGN, "FU-A").id.0 } + ); +} -- 2.47.3 From 8cc62f192e29f3ddf276cf5def1087b5df9c6f2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 21:56:22 +0100 Subject: [PATCH 4/5] move_tests: a spec's successor lands with its supersession Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/move_tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index b4cdcb3..aec267b 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -771,19 +771,21 @@ fn spec_citing(ruling: &PipelineRef) -> D { } /// A receiver holding `root` whole, in which `old` is superseded by `new` (both of that root). -fn receiver_with_superseded(root: &str, opening: Vec, old: PipelineRef, new: PipelineRef) -> Mind { +fn receiver_with_superseded(root: &str, opening: Vec, closing: Vec, old: PipelineRef, new: PipelineRef) -> Mind { let mut mind = receiver(); let mut first = vec![if root == CAMPAIGN { campaign(&[REPO]) } else { other_campaign() }]; first.extend(opening); committed(import(&mut mind, first, vec![])); - committed(import(&mut mind, vec![resolution(old, superseded(&[new]))], vec![])); + let mut second = closing; + second.push(resolution(old, superseded(&[new]))); + committed(import(&mut mind, second, vec![])); mind } #[test] fn an_imported_report_may_cite_another_campaigns_spec_superseded_since() { let (old, new) = (spec_ref(OTHER_ROOT, 1), spec_ref(OTHER_ROOT, 2)); - let mut mind = receiver_with_superseded(OTHER_ROOT, vec![spec_of(OTHER_ROOT, 1), spec_of(OTHER_ROOT, 2)], old.clone(), new); + let mut mind = receiver_with_superseded(OTHER_ROOT, vec![spec_of(OTHER_ROOT, 1)], vec![spec_of(OTHER_ROOT, 2)], old.clone(), new); committed(import(&mut mind, vec![campaign(&[REPO])], vec![])); let (_, writes) = committed(import(&mut mind, vec![report_citing(&old)], vec![])); assert_eq!(writes.len(), 1); @@ -792,7 +794,7 @@ fn an_imported_report_may_cite_another_campaigns_spec_superseded_since() { #[test] fn an_imported_report_citing_its_own_campaigns_superseded_spec_is_refused() { let (old, new) = (spec_ref(CAMPAIGN, 1), spec_ref(CAMPAIGN, 2)); - let mut mind = receiver_with_superseded(CAMPAIGN, vec![spec_of(CAMPAIGN, 1), spec_of(CAMPAIGN, 2)], old.clone(), new); + let mut mind = receiver_with_superseded(CAMPAIGN, vec![spec_of(CAMPAIGN, 1)], vec![spec_of(CAMPAIGN, 2)], old.clone(), new); assert_eq!( refusal(import(&mut mind, vec![report_citing(&old)], vec![])), MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: old.id.0 } @@ -802,7 +804,7 @@ fn an_imported_report_citing_its_own_campaigns_superseded_spec_is_refused() { #[test] fn an_imported_spec_may_cite_another_campaigns_ruling_superseded_since() { let (old, new) = (ruling_ref(OTHER_ROOT, "R1"), ruling_ref(OTHER_ROOT, "R2")); - let mut mind = receiver_with_superseded(OTHER_ROOT, vec![ruling_of(OTHER_ROOT, "R1"), ruling_of(OTHER_ROOT, "R2")], old.clone(), new); + let mut mind = receiver_with_superseded(OTHER_ROOT, vec![ruling_of(OTHER_ROOT, "R1"), ruling_of(OTHER_ROOT, "R2")], vec![], old.clone(), new); committed(import(&mut mind, vec![campaign(&[REPO])], vec![])); let (_, writes) = committed(import(&mut mind, vec![spec_citing(&old)], vec![])); assert_eq!(writes.len(), 1); @@ -811,7 +813,7 @@ fn an_imported_spec_may_cite_another_campaigns_ruling_superseded_since() { #[test] fn an_imported_spec_citing_its_own_campaigns_superseded_ruling_is_refused() { let (old, new) = (ruling_ref(CAMPAIGN, "R1"), ruling_ref(CAMPAIGN, "R2")); - let mut mind = receiver_with_superseded(CAMPAIGN, vec![ruling_of(CAMPAIGN, "R1"), ruling_of(CAMPAIGN, "R2")], old.clone(), new); + let mut mind = receiver_with_superseded(CAMPAIGN, vec![ruling_of(CAMPAIGN, "R1"), ruling_of(CAMPAIGN, "R2")], vec![], old.clone(), new); assert_eq!( refusal(import(&mut mind, vec![spec_citing(&old)], vec![])), MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: old.id.0 } -- 2.47.3 From e77e0f361be9919723088408bbe63c07db67fb00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 8 Nov 2026 21:57:38 +0100 Subject: [PATCH 5/5] admission: a report's spec is always its own campaign's, so its status check is never skipped Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 2 +- crates/huginn-mind/src/move_tests.rs | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index abdae17..40d3221 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -657,7 +657,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo } return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); }; - if !(historic && root_and_local(&report.cut_spec.0).0 != root) && !docs.in_force(K::CutSpec, &report.cut_spec.0) { + if !docs.in_force(K::CutSpec, &report.cut_spec.0) { return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); } if report.repo != spec.repo { diff --git a/crates/huginn-mind/src/move_tests.rs b/crates/huginn-mind/src/move_tests.rs index aec267b..37ef456 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -782,15 +782,6 @@ fn receiver_with_superseded(root: &str, opening: Vec, closing: Vec, old: P mind } -#[test] -fn an_imported_report_may_cite_another_campaigns_spec_superseded_since() { - let (old, new) = (spec_ref(OTHER_ROOT, 1), spec_ref(OTHER_ROOT, 2)); - let mut mind = receiver_with_superseded(OTHER_ROOT, vec![spec_of(OTHER_ROOT, 1)], vec![spec_of(OTHER_ROOT, 2)], old.clone(), new); - committed(import(&mut mind, vec![campaign(&[REPO])], vec![])); - let (_, writes) = committed(import(&mut mind, vec![report_citing(&old)], vec![])); - assert_eq!(writes.len(), 1); -} - #[test] fn an_imported_report_citing_its_own_campaigns_superseded_spec_is_refused() { let (old, new) = (spec_ref(CAMPAIGN, 1), spec_ref(CAMPAIGN, 2)); -- 2.47.3