Desk wire: the showability gate no longer shadows a stored answer's replay

answer_desk's (answerer, request_id) lookup is now bound_answer, pub(crate) in desk.rs, and
Mind::desk_answer asks it so the gate runs only when nothing is stored. Finding
desk-wire-gate-shadows-replay.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-10 19:19:16 +00:00
commit 905cdd6793
2 changed files with 38 additions and 10 deletions

View file

@ -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<S: MindStore> Mind<S> {
// 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() });
}

View file

@ -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<S: MindStore> Mind<S> {
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!() };