diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs index ca7a341..4bdec8e 100644 --- a/crates/huginn-mind/src/desk.rs +++ b/crates/huginn-mind/src/desk.rs @@ -142,6 +142,15 @@ fn counts_under(answer: &PipelineDeskAnswer, current: &Sha256Hex) -> bool { matches!(answer.outcome, DeskOutcome::Counted | DeskOutcome::Revision { .. }) && answer.options_hash == *current } +/// The answer already stored for `(answerer, request_id)`, if any: the one retry lookup, which +/// `answer_desk` runs before it reads the fork and the door runs before its showability gate. +pub(crate) fn bound_answer<'a>(docs: &'a Docs<'_>, answerer: &Short, request_id: &RequestId) -> Option<(&'a str, &'a PipelineDeskAnswer)> { + docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document { + PipelineDocument::DeskAnswer(held) if held.answerer == *answerer && held.request_id == *request_id => Some((key, held)), + _ => None, + }) +} + /// The answer that counts for `fork` now: the lowest-sequence answer whose stored /// outcome is Counted or Revision under the card's current options hash and that no /// Retraction names (R4; an answer under an older hash stops counting). Derived on @@ -318,11 +327,7 @@ impl Mind { // the fork is read, so a retry gets its stored outcome without re-validating and even if the // card has since changed. A malformed request_id binds no record and meets the probe below. // The key is (answerer, request_id). - let bound = docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document { - PipelineDocument::DeskAnswer(held) if held.answerer == stamp.answerer && held.request_id == request.request_id => Some((key, held)), - _ => None, - }); - if let Some((key, held)) = bound { + if let Some((key, held)) = bound_answer(&docs, &stamp.answerer, &request.request_id) { if held.body_sha256 != body_sha256 { return Err(MindRefusal::DeskRequestIdConflict { request_id: request.request_id.0.clone() }); } diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index b729d0a..0561b85 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -20,7 +20,7 @@ use eureka_pipeline::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, DeskStamp, counted_answer}; +use crate::desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, DeskStamp, bound_answer, counted_answer}; use crate::docs::Docs; use crate::mind::Mind; use crate::refusal::MindRefusal; @@ -323,10 +323,11 @@ impl Mind { Err(refused) => return Ok((DeskResponse::NotStored(refused), false)), }; let image = self.image()?; - let unshowable = matches!( - Docs::at(image, image.head).find(PipelineKind::Question, &sent.fork.0), - Some(PipelineDocument::Question(question)) if !showable(question, deciders) - ); + let docs = Docs::at(image, image.head); + // A request already stored replays whatever the list is now: the lookup is `answer_desk`'s, + // asked here only so the gate does not shadow it. + let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none() + && matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders)); if unshowable { return Ok((DeskResponse::NotStored(fork_unknown()), false)); } @@ -886,6 +887,28 @@ mod tests { assert_eq!(answered(ask(&mut mind, "x", asked).0).0.outcome, "Counted"); } + #[test] + fn a_stored_answer_replays_whatever_the_decider_list_is_now() { + let mut mind = desk(); + let hash = hash_of(&mind, FORK); + let request = wire(FORK, &hash); + let (stored, replayed) = ask(&mut mind, "x", request.clone()); + assert!(!replayed); + let before = head(&mind); + // The list Bifrost passes is empty now; the stored request still gets its stored answer. + let (again, replayed) = ask_as(&mut mind, "x", &[], request.clone()); + assert!(replayed); + assert_eq!(encode(&again), encode(&stored)); + assert_eq!(head(&mind), before); + // Another subject with the same bytes has stored nothing: the gate refuses it. + let (other, replayed) = ask_as(&mut mind, "y", &[], request); + assert_eq!((not_stored(other).code, replayed), (ForkRefusalCode::ForkUnknown, false)); + // And a request nobody stored is fork_unknown while the list is empty. + let fresh = wire(FORK, &hash); + assert_eq!(not_stored(ask_as(&mut mind, "x", &[], fresh).0).code, ForkRefusalCode::ForkUnknown); + assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1)); + } + #[test] fn showable_needs_two_options_text_depends_and_a_decider() { let D::Question(plain) = question("Q9", &["A", "B"], "A") else { unreachable!() };