From 69ba61f0541fba3e040ce2bc89099e68ab6078a1 Mon Sep 17 00:00:00 2001 From: Eureka Date: Mon, 9 Nov 2026 11:41:21 +0000 Subject: [PATCH 1/2] AlreadyResolved names the closure in force, so a wrong closure can be corrected A closed row is corrected by withdrawing its resolution and resolving the subject again at the next sequence; admission already allows it, in one batch too (new test). The refusal gave no way to find it: a Mind that tried to Withdraw or re-resolve the follow-up itself got AlreadyResolved with only the subject. It now carries closed_by, the resolution key to withdraw. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 37 ++++++++++++++----- crates/huginn-mind/src/refusal.rs | 3 +- .../huginn.mind_response.v1.schema.json | 7 +++- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 99dc13e..35e0bc3 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -616,8 +616,8 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo let own = |resolution: &PipelineResolution| { matches!(&resolution.outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) }; - if !docs.in_force_unless(K::Question, &question_id.0, own) { - return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() }); + if let Some((closed_by, _)) = docs.closing_resolution_unless(K::Question, &question_id.0, own) { + return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone(), closed_by: closed_by.into() }); } let choice = ruling.choice.as_ref().map_or("", |choice| choice.0.as_str()); if !question.options.iter().any(|option| option.label.0 == choice) { @@ -938,8 +938,8 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution, historic: b } // A8: a subject with a resolution still in force is closed. A withdrawn // one is not in force, so the subject is open and this is its next record. - if !docs.in_force_unless(subject_kind, subject_id, |other| other == resolution) { - return Err(MindRefusal::AlreadyResolved { subject: subject_id.into() }); + if let Some((closed_by, _)) = docs.closing_resolution_unless(subject_kind, subject_id, |other| other == resolution) { + return Err(MindRefusal::AlreadyResolved { subject: subject_id.into(), closed_by: closed_by.into() }); } let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() }; if !matrix(subject_kind, &resolution.outcome) { @@ -1580,7 +1580,7 @@ mod tests { committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); assert_eq!( refusal(admit(&mut mind, vec![resolution_n(subject.clone(), 2, ResolutionOutcome::Withdrawn { reason: "again".into() })])), - MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + MindRefusal::AlreadyResolved { subject: id("question", "Q1"), closed_by: id("resolution", "question.Q1.n1") } ); assert_eq!( refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), @@ -1591,7 +1591,7 @@ mod tests { answering.choice = Some(l("A")); assert_eq!( refusal(admit(&mut mind, vec![D::Ruling(answering)])), - MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + MindRefusal::AlreadyResolved { subject: id("question", "Q1"), closed_by: id("resolution", "question.Q1.n1") } ); } @@ -1624,7 +1624,7 @@ mod tests { // itself be withdrawn to re-raise `n1` over it (Q19 A). assert_eq!( refusal(admit(&mut mind, vec![resolution_n(subject, 3, withdrawn())])), - MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + MindRefusal::AlreadyResolved { subject: id("question", "Q1"), closed_by: id("resolution", "question.Q1.n2") } ); assert_eq!( refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), @@ -1647,6 +1647,23 @@ mod tests { ]); } + /// A closure recorded wrongly is corrected in one batch: withdraw the + /// resolution, then resolve its subject again at the next sequence. The + /// batch is read as one image, so the second record sees the subject open. + #[test] + fn a_closure_is_corrected_by_withdrawing_it_and_resolving_again_in_one_batch() { + let mut mind = with_specs(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), follow_up("FU1", r(K::Ruling, &id("ruling", "R1")))])); + let subject = r(K::FollowUp, &id("follow_up", "FU1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Recorded { reason: "done".into() })])); + let first = id("resolution", "follow_up.FU1.n1"); + committed(admit(&mut mind, vec![ + resolution(r(K::Resolution, &first), withdrawn()), + resolution_n(subject.clone(), 2, ResolutionOutcome::Fixed { commit: sha(), by: None }), + ])); + assert!(matches!(mind.get(K::Resolution, &id("resolution", "follow_up.FU1.n2")).unwrap(), Some(D::Resolution(_)))); + } + /// The sequence is exactly the previous plus one: a gap refuses as loudly /// as a taken sequence, on both sequenced kinds, and before the question /// of what still stands arises. @@ -2187,7 +2204,7 @@ mod tests { let mut again = ruling("R2"); again.answers = Some(s(&id("question", "Q1"))); again.choice = Some(l("B")); - assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") }); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1"), closed_by: id("resolution", "question.Q1.n1") }); // An explicit `Answered` must name the ruling that actually answers // its subject: a ruling answering Q1 does not resolve Q2. @@ -2867,7 +2884,7 @@ mod tests { let mut again = consolidation("m4", &["m1"]); again.pop(); again.push(resolution_n(m("m1"), 2, superseded(&[m("m4")]))); - assert_eq!(refusal(admit(&mut mind, again)), MindRefusal::AlreadyResolved { subject: key("m1") }); + assert_eq!(refusal(admit(&mut mind, again)), MindRefusal::AlreadyResolved { subject: key("m1"), closed_by: format!("{INSTANCE}:resolution:memory.m1.n1") }); } /// A Sleep run and a Persona run are one slot of hers; a Self run is the other. @@ -2916,7 +2933,7 @@ mod tests { committed(admit(&mut mind, vec![slept(night(), Some(m("m1")))])); assert_eq!( refusal(admit(&mut mind, vec![resolution_n(night(), 2, ResolutionOutcome::Slept { line: "again".into(), day_through: None })])), - MindRefusal::AlreadyResolved { subject: run_key("s") }, + MindRefusal::AlreadyResolved { subject: run_key("s"), closed_by: run_key("s").replace(":run:", ":resolution:run.") + ".n1" }, "the first closing in force is the only one" ); // An empty day names no memory. diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index fd4b52d..a5c10b6 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -27,7 +27,8 @@ pub enum MindRefusal { BatchSize { actual: u32 }, IdentityCollision { kind: PipelineKind, id: String }, MissingReference { kind: PipelineKind, id: String }, - AlreadyResolved { subject: String }, + /// `closed_by` is the resolution in force: to correct the closure, withdraw it (a resolution of `closed_by`), then resolve `subject` again at the next sequence. + AlreadyResolved { subject: String, closed_by: String }, ResolutionOutOfSequence { subject: String, expected: u32, actual: u32 }, AlreadyStewarded { repo: String }, StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 }, diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index fcdac91..80d92eb 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -1061,17 +1061,22 @@ ] }, { + "description": "`closed_by` is the resolution in force: to correct the closure, withdraw it (a resolution of `closed_by`), then resolve `subject` again at the next sequence.", "type": "object", "properties": { "AlreadyResolved": { "type": "object", "properties": { + "closed_by": { + "type": "string" + }, "subject": { "type": "string" } }, "required": [ - "subject" + "subject", + "closed_by" ] } }, -- 2.47.3 From f4e419ef45c1918ad43dfea577951642518a8ce6 Mon Sep 17 00:00:00 2001 From: Eureka Date: Mon, 9 Nov 2026 12:08:34 +0000 Subject: [PATCH 2/2] AlreadyResolved: the correction test takes closed_by from the refusal Mutating the resolution path's closed_by to the subject fails the test. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 35e0bc3..96a2d6d 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1656,9 +1656,12 @@ mod tests { committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), follow_up("FU1", r(K::Ruling, &id("ruling", "R1")))])); let subject = r(K::FollowUp, &id("follow_up", "FU1")); committed(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Recorded { reason: "done".into() })])); - let first = id("resolution", "follow_up.FU1.n1"); + // The refusal alone names the closure to withdraw: the correction takes `closed_by` from it. + let MindRefusal::AlreadyResolved { closed_by, .. } = refusal(admit(&mut mind, vec![resolution_n(subject.clone(), 2, ResolutionOutcome::Fixed { commit: sha(), by: None })])) else { + panic!("a second closing is refused as AlreadyResolved"); + }; committed(admit(&mut mind, vec![ - resolution(r(K::Resolution, &first), withdrawn()), + resolution(r(K::Resolution, &closed_by), withdrawn()), resolution_n(subject.clone(), 2, ResolutionOutcome::Fixed { commit: sha(), by: None }), ])); assert!(matches!(mind.get(K::Resolution, &id("resolution", "follow_up.FU1.n2")).unwrap(), Some(D::Resolution(_)))); -- 2.47.3