From a96c386716f3e72dcde37d20f7339608a8f1baa3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 17 Sep 2026 01:07:44 +0200 Subject: [PATCH] Pin the fourth dispatch arm, which was recorded as unpinnable `open_items` was the one read arm carrying no mutation entry. The reasoning was that 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. The second half was wrong, and Soul falsified it: a mind whose commit receipt has been deleted still opens, because the opener reads the store's types, epoch and identity and not its receipts, and then a read that joins a document to its receipt refuses by name over the orphan. That store is reachable from this crate's own surface. Soul's probe lands as the test, unchanged in substance. It admits an identity, drops the daemon, deletes the receipt row, reopens, and asserts the open-items answer is the reader's own sentence whole: `Unavailable { detail: "document epiphany.pipeline.instance.v1/yggdrasil:instance:self has no commit receipt" }`. Reaching a row needs CultCache's `CacheBackingStore` and `DatabaseEntry`, which `huginn-daemon` could not name. It does not gain a second `cultcache-rs` entry for them: `huginn-mind::store` re-exports the two traits beside the store type it already re-exports, for the reason stated there, so the workspace still pins one revision through one dependency. That is a widening of `huginn-mind`'s public surface by two re-exports, and no non-test code outside that crate touches a row. `D23` rewraps the arm's refusal as unavailable and `D23L` swallows it into an empty open set, which is what a healthy campaign with nothing open answers. Both survived the whole suite before this commit. Co-Authored-By: Claude Opus 5 --- crates/huginn-daemon/src/daemon.rs | 49 +++++++++++++++++++++++++--- crates/huginn-mind/src/store.rs | 9 +++++- tools/eureka-cut10-mutations.psd1 | 52 ++++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 9ab2dd8..5a15212 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -487,10 +487,9 @@ pub(crate) mod tests { /// 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. + /// The fourth arm, `open_items`, is pinned by the test below rather than + /// here, because the refusal it must carry needs a store to be broken + /// first. #[test] fn a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole() { let (_root, mut daemon) = seeded(); @@ -533,6 +532,48 @@ pub(crate) mod tests { assert_eq!(page.matched, 1); } + /// The fourth read arm, pinned the same way. `open_items` raises no refusal + /// of its own, so the refusal it must carry comes from the reader beneath + /// it: a mind whose commit receipt has been deleted still opens, because + /// the opener reads the store's types, epoch and identity and not its + /// receipts, and then a read that joins a document to its receipt refuses + /// by name over the orphan. The arm must return that sentence whole, like + /// its three siblings: rewrapped it says the mind is unavailable for some + /// unstated reason, and swallowed it says the campaign has nothing open, + /// which is what a healthy mind says. + /// + /// The store is broken through `huginn-mind`'s own re-export of CultCache's + /// row traits, so this crate still names one revision through one + /// dependency. Hands had recorded this arm as impossible to pin; the + /// swallow and the rewrap both survived the suite until this landed. + #[test] + fn a_refusal_open_items_raised_is_the_answer_the_dispatch_returns_whole() { + use huginn_mind::HuginnCommitReceipt; + use huginn_mind::store::{CacheBackingStore, DatabaseEntry}; + + let root = tempfile::tempdir().unwrap(); + let path = Mind::::path_for(root.path(), &slug(INSTANCE)); + { + let mut daemon = Daemon::open(root.path(), &slug(INSTANCE)).unwrap(); + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()); + assert!(matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. }))); + } + { + let mut store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + let rows = store.pull_all().unwrap(); + let receipt = + rows.iter().find(|row| row.r#type == HuginnCommitReceipt::TYPE).expect("one receipt").clone(); + store.delete(&receipt).unwrap(); + } + + let mut daemon = Daemon::open(root.path(), &slug(INSTANCE)).expect("an orphaned image still opens"); + let orphaned = MindRefusal::Unavailable { + detail: format!("document epiphany.pipeline.instance.v1/{INSTANCE}:instance:self has no commit receipt"), + }; + let items = HuginnMindRequest::OpenItems { instance: slug(INSTANCE), campaign: slug(CAMPAIGN) }; + assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(orphaned)); + } + /// 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-mind/src/store.rs b/crates/huginn-mind/src/store.rs index 58a3d66..f0b7437 100644 --- a/crates/huginn-mind/src/store.rs +++ b/crates/huginn-mind/src/store.rs @@ -5,13 +5,20 @@ //! atomicity test. Nothing else is abstracted. use anyhow::Result; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; +use cultcache_rs::CultCacheEnvelope; /// The one store a daemon opens. Re-exported so the crate that serves a mind /// names the store type without declaring `cultcache-rs` a second time: one /// dependency entry, one rev, one place to move it. pub use cultcache_rs::OwnedRedbMessagePackBackingStore; +/// CultCache's own row traits, re-exported for the same reason and used for +/// one thing: the daemon's store-integrity test reaches past this crate to +/// break a store's rows, so that a read over the broken store raises the +/// refusal its dispatch arm must carry. No non-test code outside this crate +/// touches a row directly, and nothing here abstracts them. +pub use cultcache_rs::{CacheBackingStore, DatabaseEntry}; + /// A store a mind can be opened over. `Clone` shares ownership of the same /// store (the owned redb store's clones share its lock and handle), which is /// how the cache image and the commit path hold one store between them. diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index f7c1870..b55bb72 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -27,13 +27,11 @@ # 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. +# `D17`-`D19` pin three of the dispatch's four read arms and `D23` the fourth. +# The fourth was once recorded here as unpinnable, on the grounds that +# `Mind::open_items` raises no refusal of its own; the refusal it carries comes +# from the reader beneath it over a store whose receipt is gone, and such a +# store is reachable from this crate's surface, so the entry exists. @{ Mutations = @( @{ @@ -423,6 +421,46 @@ New = @' Ok(views) => HuginnMindResponse::History(views), Err(_) => HuginnMindResponse::History(vec![]), +'@ + } + @{ + Id = 'D23' + Rule = 'A refusal `open_items` carries out of the reader crosses the dispatch as itself, not rewrapped as unavailable.' + Test = 'daemon::tests::a_refusal_open_items_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::OpenItems { campaign, .. } => match self.mind.open_items(&campaign) { + Ok(items) => HuginnMindResponse::OpenItems(items), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::OpenItems { campaign, .. } => match self.mind.open_items(&campaign) { + Ok(items) => HuginnMindResponse::OpenItems(items), + Err(refusal) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: format!("{refusal}"), + }), + }, +'@ + } + @{ + Id = 'D23L' + Rule = 'Nor is it swallowed into an empty open set, which is what a healthy campaign with nothing open answers.' + Test = 'daemon::tests::a_refusal_open_items_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Ok(items) => HuginnMindResponse::OpenItems(items), + Err(refusal) => HuginnMindResponse::Refused(refusal), +'@ + New = @' + Ok(items) => HuginnMindResponse::OpenItems(items), + Err(_) => HuginnMindResponse::OpenItems(huginn_mind::PipelineOpenItems { + questions: vec![], + findings: vec![], + follow_ups: vec![], + specs_without_report: vec![], + reports_without_verdict: vec![], + }), '@ } @{