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![], + }), '@ } @{