diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index c5a7225..8aaca41 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -211,6 +211,13 @@ fn rig(peer_uid: u32) -> Rig { } impl Rig { + /// The Mind lists the seeded fork, on its own door, so a non-decider reaches it. + fn list_the_fork(&mut self) { + let listing = PipelineDocument::DeskListing(huginn_mind::eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: short(FORK), reason: "Listed.".into() }); + let admitted = self.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![listing])), now()).answered(); + assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}"); + } + /// Runs `client` on its own thread while this one serves the door, then drains what is still waiting. fn serving(&mut self, client: impl FnOnce(&Path) -> T + Send) -> T { let path = self.path.clone(); @@ -607,6 +614,7 @@ mod door_smoke { #[test] fn deciders_ride_each_request() { let mut rig = rig(my_uid()); + rig.list_the_fork(); let (before, hash) = (rig.state(), rig.hash.clone()); let out = rig.serving(|path| { let ask = |id: &str, who: &str, deciders: &[&str], request_id: &str| { @@ -782,6 +790,7 @@ mod door_smoke { #[test] fn a_mind_refusal_and_a_stored_refusal_are_typed_replies() { let mut rig = rig(my_uid()); + rig.list_the_fork(); let (before, hash) = (rig.state(), rig.hash.clone()); let mut foreign = answer("x", DeskAuthority::Member, &["x"], wire(&hash, R1)); let DeskRequest::Answer { mind, .. } = &mut foreign else { unreachable!() }; diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index be43cc8..8022e53 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -549,10 +549,12 @@ impl Mind { }; let image = self.image()?; let docs = Docs::at(image, image.head); + // A fork this reader may not see is unshowable here too, by the read side's own `visible`: the answer path + // must not tell a hidden fork from a missing one, nor store a refusal for it. // 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)); + && matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders) || !reads::visible(&docs, &sent.fork.0, deciders.contains(&subject))); if unshowable { return Ok(DeskResponse::NotStored(fork_unknown())); } @@ -616,6 +618,11 @@ mod tests { fn desk() -> Mind { let mut mind = seeded(); committed(admit(&mut mind, vec![question_n(1), question_n(2)])); + // Listed, so a reader who is not a decider reaches them (an unlisted fork is the reads' own tests'). + for fork in [FORK, FORK2] { + let listing = D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: s(fork), reason: "Listed.".into() }); + committed(admit(&mut mind, vec![listing])); + } mind } @@ -1491,6 +1498,7 @@ mod tests { assert_eq!(shown_hash(&question).0, her_hash, "{name}: the card's hash"); let mut mind = seeded(); committed(admit(&mut mind, vec![D::Question(question)])); + committed(admit(&mut mind, vec![D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: s(FORK), reason: "Listed.".into() })])); let list: Vec<&str> = card["deciders"].as_array().unwrap().iter().map(|who| who.as_str().unwrap()).collect(); if card["status"] == "closed" { close(&mut mind, FORK, "RQ1", "no"); diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 064a6b4..499df38 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -355,7 +355,7 @@ fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, /// Whether a reader may see this fork at all: a decider sees every showable fork (they must, to answer /// it); anyone else only while the Mind's `desk_listing` for it is in force (derived from the /// resolutions on every read; no status is stored). A fork nobody may see reads as a missing one. -fn visible(docs: &Docs<'_>, key: &str, decider: bool) -> bool { +pub(super) fn visible(docs: &Docs<'_>, key: &str, decider: bool) -> bool { decider || { let (root, label) = root_and_local(key); let listing = format!("{root}:desk_listing:{label}"); @@ -1103,6 +1103,30 @@ mod tests { assert_eq!(head(&mind), before); } + /// The answer path is no oracle: a non-decider answering a hidden fork gets the bytes of answering + /// one that never existed, and nothing is stored. A listed fork still stores the NotADecider refusal. + #[test] + fn a_non_decider_answering_a_hidden_fork_learns_nothing_and_stores_nothing() { + let mut mind = desk(2); + let hash = hash_of(&mind, &fork(1)); + let before = (head(&mind), stored_answers(&mind)); + let hidden = answer(&mut mind, DAN, &[ANNA, BEN], &fork(1), &hash, None); + let missing = answer(&mut mind, DAN, &[ANNA, BEN], &fork(99), &hash, None); + unknown(hidden.clone()); + assert_eq!(rmp_serde::to_vec_named(&hidden).unwrap(), rmp_serde::to_vec_named(&missing).unwrap()); + assert_eq!((head(&mind), stored_answers(&mind)), before, "nothing stored"); + list_it(&mut mind, 1); + let listed = answer(&mut mind, DAN, &[ANNA, BEN], &fork(1), &hash, None); + let DeskResponse::Answered { answer: refused, .. } = listed else { panic!("a listed fork stores the refusal: {listed:?}") }; + assert_eq!(refused.code, Some(ForkAnswerCode::NotADecider)); + assert_eq!(stored_answers(&mind), before.1 + 1); + } + + fn stored_answers(mind: &Mind) -> usize { + let image = mind.image().unwrap(); + Docs::at(image, image.head).of_kind(PipelineKind::DeskAnswer).count() + } + #[test] fn the_desk_door_cannot_admit_a_listing() { let mut mind = desk(1);