diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index a316b34..a1a2b29 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -717,7 +717,7 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo Ok(()) } D::Resolution(resolution) => { - resolution_rule(docs, resolution)?; + resolution_rule(docs, resolution, import.is_some())?; 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 @@ -907,7 +907,13 @@ 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> { +/// +/// An import replays the giver's judgement of the cite as the giver made it, and the receiver may +/// hold a referent of another root whose closing it imported earlier (`replay`): that referent was +/// in force when the giver cited it, and the replay of this root cannot reorder another root's +/// history. The referent must still be held (A7); only its force is not asked. A referent of the +/// subject's own root replays in the giver's order and is judged as ever. +fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution, replay: bool) -> Result<(), MindRefusal> { use PipelineDocument as D; use PipelineKind as K; let subject_kind = resolution.subject.kind; @@ -977,7 +983,8 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution) -> Result<( } 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) { + let foreign_replay = replay && root_and_local(&referent.id.0).0 != root_and_local(subject_id).0; + if role != CitationRole::DayThrough && !foreign_replay && !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..b2c7128 100644 --- a/crates/huginn-mind/src/move_tests.rs +++ b/crates/huginn-mind/src/move_tests.rs @@ -638,3 +638,39 @@ fn a_campaign_cannot_be_moved_out_of_its_minds_own_instance_root() { MindRefusal::ImportOutsideCampaign { root: RECEIVER.into() } ); } + +/// The giver cited another root's follow-up while it stood open and that follow-up closed later; +/// the receiver imported the other root first, so it holds the follow-up already closed. The +/// replay of this root's citation is not refused for that. A follow-up of the root itself is +/// replayed in the giver's order and is still judged in force, and a native write meets the rule. +#[test] +fn import_replays_a_cite_of_another_roots_follow_up_that_closed_since() { + let mut mind = receiver(); + committed(admit(&mut mind, vec![other_question("Q5"), other_follow_up("FU-9", "Q5")])); + let foreign = r(K::FollowUp, &format!("{OTHER_ROOT}:follow_up:FU-9")); + committed(admit(&mut mind, vec![resolution(foreign.clone(), ResolutionOutcome::Recorded { reason: "done".into() })])); + assert!(matches!(view_of(&mind, K::FollowUp, &foreign.id.0).status, PipelineStatus::Resolved { .. })); + + let note = idea("p", "note", "an idea"); + let note_ref = r(K::Idea, &pipeline_key(¬e).unwrap()); + let own = follow_up("FU-1", r(K::Question, &id("question", "Q1"))); + let own_ref = r(K::FollowUp, &pipeline_key(&own).unwrap()); + committed(import(&mut mind, vec![campaign(&[REPO]), question("Q1", &["A", "B"], "A"), own.clone(), note.clone()], vec![])); + committed(import(&mut mind, vec![resolution(own_ref.clone(), ResolutionOutcome::Recorded { reason: "done".into() })], vec![])); + + let defer = |to: &PipelineRef| resolution(note_ref.clone(), ResolutionOutcome::Deferred { to: to.clone() }); + // The same-root referent is closed in this receiver as it was in the giver: still refused. + assert_eq!( + refusal(import(&mut mind, vec![defer(&own_ref)], vec![])), + MindRefusal::CitesResolvedDocument { kind: K::FollowUp, id: own_ref.id.0.clone() } + ); + // A native write is not a replay, and meets the rule. + assert_eq!( + refusal(admit(&mut mind, vec![defer(&foreign)])), + MindRefusal::CitesResolvedDocument { kind: K::FollowUp, id: foreign.id.0.clone() } + ); + // The replay of the foreign cite lands, and the follow-up it cites is untouched. + let (_, writes) = committed(import(&mut mind, vec![defer(&foreign)], vec![])); + assert_eq!(writes.len(), 1); + assert!(matches!(view_of(&mind, K::Idea, ¬e_ref.id.0).status, PipelineStatus::Resolved { .. })); +}