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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-17 01:07:44 +02:00
commit a96c386716
3 changed files with 98 additions and 12 deletions

View file

@ -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::<OwnedRedbMessagePackBackingStore>::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]

View file

@ -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.

View file

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