From 25a841dc6d83ae1bfc3708bdd3848b2c15178f93 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 23:50:35 +0200 Subject: [PATCH] Pin a read's refusal as crossing the dispatch unchanged The dispatch was honest and nothing forbade it flattening a refusal the mind raised: mutants that rewrapped a view refusal as unavailable, swallowed one into an empty answer, and rewrapped a query refusal all survived both suites. Flattening is the failure this cut exists to prevent, so it is now a test. The new daemon test drives each read method into a refusal the method itself raises, through `Daemon::handle`, with the shapes Soul reached over the wire: a reference whose kind and id disagree for `view` and for a subject `history`, and a semantic query for `query`, which is unavailable until Cut 11 wires an index. Each must arrive as the mind spelled it. The same reads answer when asked something answerable, so the arms are refusing on the mind's word rather than on their own shape. `D17`-`D19` rewrap each refusal and `D17L`-`D19L` swallow each into the empty answer of its own kind. `open_items` is not pinned and says so rather than carrying a weak entry: it raises no refusal of its own, and its only failing paths are store integrity inside the reader, which nothing on the daemon's surface can drive without a corrupt store. A mutant against that arm could not fail. The operation comparison is pinned in the same pass, because it shares this entries file. Its mismatch case compared two words that differ under any comparison, so a case-insensitive comparison survived; `Admit` now stands beside `query` as a malformed envelope, and `D5L2` folds case. Co-Authored-By: Claude Opus 5 --- crates/huginn-daemon/src/daemon.rs | 55 +++++++++++++ crates/huginn-daemon/src/serve.rs | 6 +- tools/eureka-cut10-mutations.psd1 | 122 ++++++++++++++++++++++++++++- 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 2e3c04f..c468b39 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -110,6 +110,7 @@ pub(crate) mod tests { use huginn_mind::wire::MindStatus; use huginn_mind::{ Faculty, HistoryScope, PipelineAdmissionBatch, PipelineProvenance, PipelineQuery, PipelineStatus, + SemanticQuery, }; use std::sync::{Arc, Mutex}; use tempfile::TempDir; @@ -348,6 +349,60 @@ pub(crate) mod tests { assert_eq!(status(&mut daemon).documents, 1, "nothing landed"); } + /// A refusal a read method raised is the answer the dispatch returns, whole. + /// The instance check refuses before the method runs and is pinned above; + /// these are the refusals the methods themselves raise, so nothing here is + /// the daemon's own. Each must arrive as the mind spelled it: not rewrapped + /// as `Unavailable`, not rewrapped as another read's refusal, and not + /// swallowed into an empty answer, which would read as "no such document" + /// rather than "that is not a reference". + /// + /// `open_items` has no refusal of its own: its only failing paths are store + /// integrity inside the reader, which the daemon's surface cannot drive + /// without a corrupt store. So the daemon's `OpenItems` rewrap is not + /// pinned here and carries no mutation entry. + #[test] + fn a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole() { + let (_root, mut daemon) = seeded(); + + // A reference whose kind and id disagree names no document any writer + // could have composed; the leaf's own refusal says so. + let invalid = PipelineRef { kind: PipelineKind::Question, id: "not a reference".into() }; + let malformed = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "ref.id".into(), + value: "not a reference".into(), + }); + let view = HuginnMindRequest::View { instance: slug(INSTANCE), id: invalid.clone() }; + assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(malformed.clone())); + + let history = HuginnMindRequest::History { instance: slug(INSTANCE), scope: HistoryScope::Subject(invalid) }; + assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(malformed)); + + // A semantic query is unavailable until Cut 11 wires an index, and the + // detail is the mind's own sentence. + let query = PipelineQuery { + semantic: Some(SemanticQuery { text: "who owns the state".into(), top_k: 4 }), + ..PipelineQuery::default() + }; + let unwired = + MindRefusal::Unavailable { detail: "semantic query: the index is not wired (Cut 11)".into() }; + assert_eq!( + daemon.handle(HuginnMindRequest::Query { instance: slug(INSTANCE), query }, now()), + HuginnMindResponse::Refused(unwired) + ); + + // And the same reads answer rather than refuse when they are asked + // something answerable, so the arms above are refusing on the mind's + // word and not on their own shape. + let absent = + PipelineRef { kind: PipelineKind::Question, id: Short(format!("{CAMPAIGN}:question:Q1")) }; + let present = HuginnMindRequest::View { instance: slug(INSTANCE), id: absent }; + assert_eq!(daemon.handle(present, now()), HuginnMindResponse::View(None)); + let plain = HuginnMindRequest::Query { instance: slug(INSTANCE), query: PipelineQuery::default() }; + let HuginnMindResponse::Query(page) = daemon.handle(plain, now()) else { panic!("expected a page") }; + assert_eq!(page.matched, 1); + } + /// Cut 11's seam: the index is handed every landed write, derived ones /// included, and can read each; an index that fails changes nothing. #[test] diff --git a/crates/huginn-daemon/src/serve.rs b/crates/huginn-daemon/src/serve.rs index 4d561a6..6c6ac65 100644 --- a/crates/huginn-daemon/src/serve.rs +++ b/crates/huginn-daemon/src/serve.rs @@ -288,7 +288,11 @@ mod tests { request("m-4", MIND_SERVICE_ID, "admit", MIND_REQUEST_SCHEMA, STANDARD.encode([0xc1_u8, 0xc1])), "payload-not-a-request", ), - ("m-5", request("m-5", MIND_SERVICE_ID, "query", MIND_REQUEST_SCHEMA, admit), "operation-mismatch"), + ("m-5", request("m-5", MIND_SERVICE_ID, "query", MIND_REQUEST_SCHEMA, admit.clone()), "operation-mismatch"), + // The same operation under another casing is another name, not this + // one: the comparison is of bytes, so `Admit` is no more `admit` + // than `query` is. + ("m-5b", request("m-5b", MIND_SERVICE_ID, "Admit", MIND_REQUEST_SCHEMA, admit), "operation-mismatch"), ]; for (id, message, code) in cases { let reply = answer(&mut daemon, ®istry, message, now()); diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index bd61cf6..55acba6 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -1,4 +1,4 @@ -# Cut 10 mutations, D1-D16: the CultNet surface's rules. Each rule has a +# Cut 10 mutations, D1-D19: the CultNet surface's rules. Each rule has a # revert, `Dn`, which removes the rule, and a loosening, `DnL`, which weakens # it rather than removing it, because a plain revert is the easy target. Every # entry names the test that must fail while it is applied. Run through @@ -26,6 +26,14 @@ # - `D12`'s order, weakened as far as it goes by `D12L`: a hub bound before the # mind is opened and then dropped when the mind refuses. Any weaker form is # the correct order again. +# +# One rule is deliberately not pinned and carries no entry rather than a weak +# one: the dispatch's `OpenItems` arm passes a refusal through like its three +# siblings, but `Mind::open_items` has no refusal of its own. Its only failing +# paths are store integrity inside the reader, which nothing on the daemon's +# surface can drive without a corrupt store, so a mutant that rewrapped or +# swallowed that arm's refusal could not fail any test here. `D17`-`D19` pin +# the three arms that can be driven. @{ Mutations = @( @{ @@ -229,6 +237,118 @@ Old = ' if operation != request.operation() {' New = ' if operation.len() != request.operation().len() {' } + @{ + Id = 'D17' + Rule = 'A refusal `view` raised crosses the dispatch as itself, not rewrapped as unavailable.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::View { id, .. } => match self.mind.view(&id) { + Ok(view) => HuginnMindResponse::View(view), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::View { id, .. } => match self.mind.view(&id) { + Ok(view) => HuginnMindResponse::View(view), + Err(refusal) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: format!("{refusal}"), + }), + }, +'@ + } + @{ + Id = 'D17L' + Rule = 'It is not swallowed into an empty answer either: a malformed reference is not an absent document.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { +'@ + New = @' + Err(_) => HuginnMindResponse::View(None), + }, + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { +'@ + } + @{ + Id = 'D18' + Rule = 'A refusal `query` raised crosses the dispatch as itself, with the mind''s own detail.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: format!("a query was refused: {refusal}"), + }), + }, +'@ + } + @{ + Id = 'D18L' + Rule = 'Nor is it flattened into an empty page, which would read as a mind holding nothing.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(refusal), +'@ + New = @' + Ok(page) => HuginnMindResponse::Query(page), + Err(_) => HuginnMindResponse::Query(huginn_mind::PipelineQueryPage { items: vec![], matched: 0 }), +'@ + } + @{ + Id = 'D19' + Rule = 'A refusal `history` raised crosses the dispatch as itself, not as another read''s refusal.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::History { scope, .. } => match self.mind.history(&scope) { + Ok(views) => HuginnMindResponse::History(views), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::History { scope, .. } => match self.mind.history(&scope) { + Ok(views) => HuginnMindResponse::History(views), + Err(_) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: "semantic query: the index is not wired (Cut 11)".into(), + }), + }, +'@ + } + @{ + Id = 'D19L' + Rule = 'Nor is a history refusal swallowed into an empty history, which is the answer for a subject with no records.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Ok(views) => HuginnMindResponse::History(views), + Err(refusal) => HuginnMindResponse::Refused(refusal), +'@ + New = @' + Ok(views) => HuginnMindResponse::History(views), + Err(_) => HuginnMindResponse::History(vec![]), +'@ + } + @{ + Id = 'D5L2' + Rule = 'The operation is compared as bytes: another casing of the name is another name.' + Test = 'serve::tests::a_malformed_envelope_is_answered_with_a_failure_and_touches_no_mind' + File = 'crates/huginn-daemon/src/envelope.rs' + Old = ' if operation != request.operation() {' + New = ' if !operation.eq_ignore_ascii_case(request.operation()) {' + } @{ Id = 'D6' Rule = 'The index never decides an outcome: a sink that fails does not refuse a batch the mind admitted.'