diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 3570c25..9892376 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -622,6 +622,126 @@ pub(crate) mod tests { assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(orphaned)); } + /// The fault the test above drives sits outside the campaign asked about: + /// the orphan is the mind's own identity document, rooted at the instance, + /// not at `CAMPAIGN`. A loosening that only refuses a fault *outside* the + /// requested campaign, and one that swallows every fault except a missing + /// receipt, both survive it. This drives the same arm with a fault on a + /// document rooted inside the requested campaign, of each kind Soul named: + /// a missing receipt, a document that does not decode, and a document two + /// receipts both claim to have written. + #[test] + fn open_items_refuses_on_a_fault_inside_the_requested_campaign() { + use huginn_mind::HuginnCommitReceipt; + use huginn_mind::store::{CacheBackingStore, DatabaseEntry}; + + // A campaign-rooted document (a cut spec) admitted alone, so its own + // receipt writes nothing else and can be found and broken without + // touching the identity or stewardship receipt from the seed batch. + fn campaign_rooted_cut_spec( + root: &std::path::Path, + cut: &str, + ) -> (String, OwnedRedbMessagePackBackingStore) { + let mut daemon = Daemon::open(root, &slug(INSTANCE)).unwrap(); + let seeded = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, campaign_seed())), now()); + assert!(matches!(seeded, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. }))); + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![cut_spec(cut, 1)])), now()); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = outcome else { + panic!("expected a commit"); + }; + assert_eq!(writes.len(), 1, "a cut spec alone derives nothing"); + let cut_spec_id = writes[0].id.0.clone(); + drop(daemon); + (cut_spec_id, OwnedRedbMessagePackBackingStore::new(&Mind::::path_for(root, &slug(INSTANCE))).unwrap()) + } + + let cut_spec_type = PipelineKind::CutSpec.type_id(); + + // A missing receipt, this time on a document rooted in `CAMPAIGN`. + { + let root = tempfile::tempdir().unwrap(); + let (cut_spec_id, mut store) = campaign_rooted_cut_spec(root.path(), "orphan-in-campaign"); + let rows = store.pull_all().unwrap(); + let receipt_row = rows + .iter() + .find(|row| { + row.r#type == HuginnCommitReceipt::TYPE + && rmp_serde::from_slice::(&row.payload) + .map(|receipt| receipt.writes.iter().any(|write| write.document_key == cut_spec_id)) + .unwrap_or(false) + }) + .expect("the cut spec's own receipt") + .clone(); + store.delete(&receipt_row).unwrap(); + drop(store); + + let mut daemon = Daemon::open(root.path(), &slug(INSTANCE)).expect("an orphaned image still opens"); + let expected = MindRefusal::Unavailable { + detail: format!("document {cut_spec_type}/{cut_spec_id} has no commit receipt"), + }; + let items = HuginnMindRequest::OpenItems { instance: slug(INSTANCE), campaign: slug(CAMPAIGN) }; + assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(expected)); + } + + // An undecodable stored document, rooted in `CAMPAIGN`. + { + let root = tempfile::tempdir().unwrap(); + let (cut_spec_id, mut store) = campaign_rooted_cut_spec(root.path(), "garbled-in-campaign"); + let rows = store.pull_all().unwrap(); + let mut doc_row = + rows.iter().find(|row| row.r#type == cut_spec_type && row.key == cut_spec_id).unwrap().clone(); + store.delete(&doc_row).unwrap(); + doc_row.payload = b"not messagepack".to_vec(); + store.push(&doc_row).unwrap(); + drop(store); + + let mut daemon = Daemon::open(root.path(), &slug(INSTANCE)).expect("a garbled document still opens"); + let items = HuginnMindRequest::OpenItems { instance: slug(INSTANCE), campaign: slug(CAMPAIGN) }; + let HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }) = daemon.handle(items, now()) + else { + panic!("a document that does not decode must refuse, not answer"); + }; + assert!( + detail.starts_with(&format!("stored document {cut_spec_id} does not decode")), + "unexpected detail: {detail}" + ); + } + + // A document two receipts both claim to have written, rooted in + // `CAMPAIGN`: the cut spec's own receipt, cloned under a new key and + // id, so the same identity is now landed twice. + { + let root = tempfile::tempdir().unwrap(); + let (cut_spec_id, mut store) = campaign_rooted_cut_spec(root.path(), "doubled-in-campaign"); + let rows = store.pull_all().unwrap(); + let receipt_row = rows + .iter() + .find(|row| { + row.r#type == HuginnCommitReceipt::TYPE + && rmp_serde::from_slice::(&row.payload) + .map(|receipt| receipt.writes.iter().any(|write| write.document_key == cut_spec_id)) + .unwrap_or(false) + }) + .expect("the cut spec's own receipt") + .clone(); + let mut receipt: HuginnCommitReceipt = rmp_serde::from_slice(&receipt_row.payload).unwrap(); + receipt.receipt_id = format!("{}-forged", receipt.receipt_id); + let mut forged_row = receipt_row.clone(); + forged_row.key = receipt.receipt_id.clone(); + forged_row.payload = rmp_serde::to_vec_named(&receipt).unwrap(); + store.push(&forged_row).unwrap(); + drop(store); + + let mut daemon = + Daemon::open(root.path(), &slug(INSTANCE)).expect("two receipts for one document still opens"); + let expected = MindRefusal::Unavailable { + detail: format!("document {cut_spec_type}/{cut_spec_id} is written by two receipts"), + }; + let items = HuginnMindRequest::OpenItems { instance: slug(INSTANCE), campaign: slug(CAMPAIGN) }; + assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(expected)); + } + } + /// 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/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index 43f85fb..f8b4cfd 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -1,13 +1,13 @@ # Cut 10 mutations, D1-D22: 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 -# Epiphany's harness from this repo: +# entry names the test that must fail while it is applied. Run through the +# Eureka skill's harness from this repo: # # $env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' -# powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` +# powershell -File C:\Users\Meta\.claude\skills\eureka\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` # -Entries tools/eureka-cut10-mutations.psd1 ` -# -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/wire.rs,crates/huginn-daemon/src/daemon.rs,crates/huginn-daemon/src/envelope.rs,crates/huginn-daemon/src/serve.rs,schemas/cultnet/huginn.mind_request.v1.schema.json,schemas/cultnet/huginn.mind_response.v1.schema.json ` +# -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/query.rs,crates/huginn-mind/src/wire.rs,crates/huginn-daemon/src/daemon.rs,crates/huginn-daemon/src/envelope.rs,crates/huginn-daemon/src/serve.rs,schemas/cultnet/huginn.mind_request.v1.schema.json,schemas/cultnet/huginn.mind_response.v1.schema.json ` # -Test 'cargo test -p huginn-daemon --lib' # # Entries whose test lives in `huginn-mind` carry their own `Command`. `D1` and @@ -540,6 +540,56 @@ pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> specs_without_report: vec![], reports_without_verdict: vec![], }), +'@ + } + @{ + Id = 'S1' + Rule = 'open_items refuses on a fault anywhere in the image it must read, not only on one outside the requested campaign.' + Test = 'daemon::tests::open_items_refuses_on_a_fault_inside_the_requested_campaign' + Command = 'cargo test -p huginn-daemon --lib' + File = 'crates/huginn-mind/src/query.rs' + Old = @' + let reader = Reader::new(self)?; + let views = reader + .views()? + .into_iter() + .filter(|view| root_and_local(&view.id.id.0).0 == campaign.0) + .collect::>(); +'@ + New = @' + let views = match Reader::new(self).and_then(|reader| reader.views()) { + Ok(views) => views, + Err(MindRefusal::Unavailable { detail }) if detail.contains(campaign.0.as_str()) => Vec::new(), + Err(other) => return Err(other), + } + .into_iter() + .filter(|view| root_and_local(&view.id.id.0).0 == campaign.0) + .collect::>(); +'@ + } + @{ + Id = 'S1d' + Rule = 'It refuses on every kind of fault a document can carry, not only a missing receipt.' + Test = 'daemon::tests::open_items_refuses_on_a_fault_inside_the_requested_campaign' + Command = 'cargo test -p huginn-daemon --lib' + File = 'crates/huginn-mind/src/query.rs' + Old = @' + let reader = Reader::new(self)?; + let views = reader + .views()? + .into_iter() + .filter(|view| root_and_local(&view.id.id.0).0 == campaign.0) + .collect::>(); +'@ + New = @' + let views = match Reader::new(self).and_then(|reader| reader.views()) { + Ok(views) => views, + Err(MindRefusal::Unavailable { detail }) if !detail.contains("has no commit receipt") => Vec::new(), + Err(other) => return Err(other), + } + .into_iter() + .filter(|view| root_and_local(&view.id.id.0).0 == campaign.0) + .collect::>(); '@ } @{