Merge pull request 'AlreadyResolved names the closure in force' (#22) from eureka/refusal-names-the-closure into main

This commit is contained in:
Eureka 2026-11-09 12:10:30 +00:00
commit 05f593476e
3 changed files with 38 additions and 12 deletions

View file

@ -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,26 @@ 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() })]));
// 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, &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(_))));
}
/// 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 +2207,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 +2887,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 +2936,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.

View file

@ -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 },

View file

@ -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"
]
}
},