From 65c46aa59db7b6d265dcf5820e14b2534e5b7e2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 08:21:16 +0200 Subject: [PATCH] Residue batch on Cut 10's fourth fix batch: seal path_for, fill N1's gaps, pin the gate's other axis S3: Mind::path_for bypassed the grammar door and the daemon's own tests constructed stores through it directly. It is now private; a test-only Mind::store_path_for validates through require_grammatical_slug first, gated behind cfg(test) here and a test-support feature dev-dependents opt into (huginn-daemon's Cargo.toml). The four daemon test call sites and one huginn-mind test call site now go through the door. S2: N1's door, require_grammatical_slug, had no mutation entry for two of its three callers (open_with and require_instance survived deleting it; open already had N1's own escape test). Added a test per caller and a mutation entry for each of the three deletions (N1open, N1openwith, N1requireinstance) in tools/eureka-cut10-mutations.psd1. S4: a Unicode hyphen (U+2010) folded to ASCII '-' before the grammar check survived. Added a fixture and mutation entry (S4fold) pinning that no such fold runs before Slug::validate_slug. S1: the size-gate fixtures varied operation and runtime id together (4/16, 4/16, 10/19), so a formula tracking one and dropping the other still passed. Added a fixture varying them independently (10/16 and 4/19) and two mutation entries (N4c, N4d) for the two formulas that each drop one term. Verified: cargo test -p huginn-mind --lib (65/65), cargo test -p huginn-daemon --lib (20/20, one more than before: the new independent-axis gate test). Mutation suites through the Eureka harness: Cut 8 67/67 killed, Cut 9 38/38 killed, Cut 10 77/77 killed (71 before this batch, +6: N1open, N1openwith, N1requireinstance, S4fold, N4c, N4d). M0 green in every suite. Co-Authored-By: Claude Sonnet 5 --- crates/huginn-daemon/Cargo.toml | 6 ++ crates/huginn-daemon/src/daemon.rs | 8 +- crates/huginn-daemon/src/serve.rs | 31 +++++++ crates/huginn-mind/Cargo.toml | 8 ++ crates/huginn-mind/src/admission.rs | 5 +- crates/huginn-mind/src/mind.rs | 74 +++++++++++++++- tools/eureka-cut10-mutations.psd1 | 130 ++++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 7 deletions(-) diff --git a/crates/huginn-daemon/Cargo.toml b/crates/huginn-daemon/Cargo.toml index 6ce886d..9b434e3 100644 --- a/crates/huginn-daemon/Cargo.toml +++ b/crates/huginn-daemon/Cargo.toml @@ -30,3 +30,9 @@ signal-hook = "0.3" [dev-dependencies] tempfile = "3" + +# Test-only: `Mind::store_path_for`, the grammar-checked door onto a raw +# store's path. The daemon's own store-integrity tests reach a store directly +# (to break a row on purpose) and must go through the same door `open` does +# rather than reconstructing the path by hand (S3, residue batch). +huginn-mind = { path = "../huginn-mind", features = ["test-support"] } diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index d7db38a..b0d2c3e 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -626,7 +626,7 @@ pub(crate) mod tests { use huginn_mind::store::{CacheBackingStore, DatabaseEntry}; let root = tempfile::tempdir().unwrap(); - let path = Mind::::path_for(root.path(), &slug(INSTANCE)); + let path = Mind::::store_path_for(root.path(), &slug(INSTANCE)).unwrap(); { let mut daemon = Daemon::open(root.path(), &slug(INSTANCE)).unwrap(); let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()); @@ -678,7 +678,7 @@ pub(crate) mod tests { 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()) + (cut_spec_id, OwnedRedbMessagePackBackingStore::new(&Mind::::store_path_for(root, &slug(INSTANCE)).unwrap()).unwrap()) } let cut_spec_type = PipelineKind::CutSpec.type_id(); @@ -812,7 +812,7 @@ pub(crate) mod tests { fn the_daemon_refuses_loudly_when_it_cannot_open_the_mind_and_binds_nothing() { let root = tempfile::tempdir().unwrap(); let held = Daemon::open(root.path(), &slug(INSTANCE)).unwrap(); - let path = Mind::::path_for(root.path(), &slug(INSTANCE)); + let path = Mind::::store_path_for(root.path(), &slug(INSTANCE)).unwrap(); assert_eq!( Daemon::open(root.path(), &slug(INSTANCE)).err(), Some(MindRefusal::MindAlreadyOwned { path: path.display().to_string() }) @@ -822,7 +822,7 @@ pub(crate) mod tests { let mut planted = Daemon::open(root.path(), &slug(INSTANCE)).unwrap(); planted.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()); drop(planted); - let moved = Mind::::path_for(root.path(), &slug(OTHER)); + let moved = Mind::::store_path_for(root.path(), &slug(OTHER)).unwrap(); std::fs::create_dir_all(moved.parent().unwrap()).unwrap(); std::fs::copy(&path, &moved).unwrap(); assert_eq!( diff --git a/crates/huginn-daemon/src/serve.rs b/crates/huginn-daemon/src/serve.rs index 5170ba0..d44a0b7 100644 --- a/crates/huginn-daemon/src/serve.rs +++ b/crates/huginn-daemon/src/serve.rs @@ -873,6 +873,37 @@ mod tests { } } + /// Residue S1: every fixture up to this one, including the pair above, + /// moves `operation` and the runtime id together -- both short (`view`, + /// 4 bytes / `huginn-yggdrasil`, 16) or both long (`open_items`, 10 / + /// `huginn-thought-cage`, 19) -- so a formula that tracks only one of the + /// two and ignores the other still lands on the right answer at both + /// points by coincidence. This pins a point where they move apart: a long + /// operation with the short runtime id, and a short operation with the + /// long one, so a gate that dropped either term is wrong at one of them. + #[test] + fn the_gates_boundary_holds_when_operation_and_runtime_id_vary_independently() { + for (operation, runtime_id) in [("open_items", "huginn-yggdrasil"), ("view", "huginn-thought-cage")] { + let at = sized_exactly_full("m-g", operation, runtime_id, MAX_RESPONSE_BYTES); + let over = sized_exactly_full("m-g", operation, runtime_id, MAX_RESPONSE_BYTES + 1); + assert_eq!( + within_window(at.clone(), "m-g", operation, runtime_id), + at, + "{operation}/{runtime_id}: exactly the limit passes" + ); + + let refused = within_window(over.clone(), "m-g", operation, runtime_id); + let (correlation, answered) = decode_response(&refused).unwrap(); + assert_eq!(correlation, "m-g"); + let HuginnMindResponse::Refused(MindRefusal::ResponseTooLarge { bytes, limit }) = + answered.expect("a refusal is an answer, not an envelope failure") + else { + panic!("{operation}/{runtime_id}: one byte over the limit was not refused: {refused:?}"); + }; + assert_eq!((bytes, limit), (MAX_RESPONSE_BYTES + 1, MAX_RESPONSE_BYTES), "{operation}/{runtime_id}"); + } + } + /// One cut spec's own reference, by its cut label. fn spec_ref(daemon: &mut Daemon, cut: &str) -> PipelineRef { let query = PipelineQuery { diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 7f752b2..b6e62b9 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -23,3 +23,11 @@ sha2 = "0.10" [dev-dependencies] serde_json = "1" tempfile = "3" + +# `test-support` exposes `Mind::store_path_for`, a grammar-checked path +# accessor tests in other crates need to reach a raw store directly. It buys +# no runtime surface: gated `#[cfg(any(test, feature = "test-support"))]`, so +# it compiles only under `cargo test` in this crate or when a dependent's own +# dev-dependencies enable it (see huginn-daemon/Cargo.toml). +[features] +test-support = [] diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 5240cb6..3bcef73 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -752,7 +752,10 @@ mod tests { let root = tempfile::tempdir().unwrap(); let mut yggdrasil = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); let mut thought_cage = Mind::open(root.path(), &slug(OTHER_INSTANCE)).unwrap(); - assert_ne!(Mind::path_for(root.path(), &slug(INSTANCE)), Mind::path_for(root.path(), &slug(OTHER_INSTANCE))); + assert_ne!( + Mind::store_path_for(root.path(), &slug(INSTANCE)).unwrap(), + Mind::store_path_for(root.path(), &slug(OTHER_INSTANCE)).unwrap() + ); committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)])); let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 8ea99d2..84f1228 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -100,11 +100,26 @@ pub struct Mind { impl Mind { /// `/minds//mind.redb`. Derived for convenience; - /// the `instance` document inside is the identity. - pub fn path_for(state_root: &Path, instance: &Slug) -> PathBuf { + /// the `instance` document inside is the identity. Private: every caller + /// that can reach a raw path must go through the grammar door first, so + /// this is never exposed on its own. `open` calls it only after + /// `require_grammatical_slug` has already passed. + fn path_for(state_root: &Path, instance: &Slug) -> PathBuf { state_root.join("minds").join(&instance.0).join("mind.redb") } + /// Test-only door onto the same path: validates through + /// `require_grammatical_slug` first, exactly as `open` does, so a test + /// that must reach the raw store directly (to break a row on purpose) + /// cannot bypass the grammar the way the old public `path_for` did. Built + /// only for `cfg(test)` in this crate and for `huginn-daemon`'s own tests + /// via the `test-support` feature; never compiled into a release binary. + #[cfg(any(test, feature = "test-support"))] + pub fn store_path_for(state_root: &Path, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + Ok(Self::path_for(state_root, instance)) + } + /// Opens the instance's mind under the state root, taking the store's /// exclusive lock for the mind's lifetime. A second owner of the same /// path, in this process or another, is `MindAlreadyOwned`. An empty @@ -592,4 +607,59 @@ mod tests { assert!(!outer.path().join("escaped").join("mind.redb").exists(), "no store escaped the state root"); assert!(!inner.join("minds").exists(), "no store was created under the state root either"); } + + /// S2: `open_with` is the door `open` shares with every store already in + /// hand (planted directly, or opened by a test through `store_path_for`), + /// so it must run `require_grammatical_slug` too, not only `open`'s own + /// path-joining caller. A store need not hold anything for this to + /// refuse: the declared name is checked before the store is even pulled. + #[test] + fn open_with_refuses_a_declared_instance_outside_the_grammar() { + let store = MemoryStore::new(); + let bad = Slug("not a slug!".into()); + assert_eq!( + Mind::open_with(store.clone(), &bad).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: bad.0.clone(), + })) + ); + assert_eq!(store.pull_count(), 0, "refused before the store was ever pulled"); + } + + /// S2: `require_instance` is A1 for admission and the daemon's reads + /// alike, and it runs the same grammar door on the *declared* name before + /// comparing it to the mind's own. A declared name outside the grammar is + /// `InvalidFormat { field: "declared" }`, never folded into a comparison + /// or silently treated as merely foreign. + #[test] + fn require_instance_refuses_a_declared_name_outside_the_grammar() { + let mind = crate::fixtures::seeded(); + let bad = Slug("not a slug!".into()); + assert_eq!( + mind.require_instance(&bad).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "declared".into(), + value: bad.0.clone(), + })) + ); + } + + /// S4: a Unicode hyphen (U+2010) is not ASCII `-`, so a grammatical + /// `Slug` never contains one and a name carrying it is refused by the + /// leaf's own grammar as written -- nothing here may fold it to plain + /// `-` before asking `Slug::validate_slug`, the way `S3wide`'s fullwidth + /// mutant folds a fullwidth letter to its ASCII form. + #[test] + fn a_unicode_hyphen_is_not_folded_before_the_grammar_check() { + let declared = Slug(format!("ab{}cd", '\u{2010}')); + assert!(!declared.0.is_ascii(), "U+2010 is not ASCII, unlike plain '-'"); + assert_eq!( + Mind::open_with(MemoryStore::new(), &declared).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: declared.0.clone(), + })) + ); + } } diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index 7a689df..46577c4 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -232,6 +232,88 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu }) }) } +'@ + } + # Residue S2: N1's door, `require_grammatical_slug`, had no mutation + # entry for two of its three callers. `open`'s deletion is covered by + # N1's own escape test; `open_with`'s survived all 81 tests before + # this batch added one that names it; `require_instance`'s is covered + # the same way, since neither caller's test constructs an ungrammatical + # name the other way. + @{ + Id = 'N1open' + Rule = 'N1: `open` checks the declared name''s grammar before it ever joins a path from it.' + Test = 'mind::tests::a_slug_outside_the_grammar_cannot_escape_the_state_root' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + pub fn open(state_root: &Path, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + let path = Self::path_for(state_root, instance); +'@ + New = @' + pub fn open(state_root: &Path, instance: &Slug) -> Result { + let path = Self::path_for(state_root, instance); +'@ + } + @{ + Id = 'N1openwith' + Rule = 'N1: `open_with` checks the declared name''s grammar too, for every store already in hand and not only for one `open` derived a path for.' + Test = 'mind::tests::open_with_refuses_a_declared_instance_outside_the_grammar' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + pub fn open_with(store: S, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + let raw = store.pull_all().map_err(unavailable)?; +'@ + New = @' + pub fn open_with(store: S, instance: &Slug) -> Result { + let raw = store.pull_all().map_err(unavailable)?; +'@ + } + @{ + Id = 'N1requireinstance' + Rule = 'N1: `require_instance` checks the declared name''s grammar before comparing it to the mind''s own, so an ungrammatical name is refused by name rather than merely being foreign.' + Test = 'mind::tests::require_instance_refuses_a_declared_name_outside_the_grammar' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> { + require_grammatical_slug("declared", declared)?; + if declared != self.instance() { +'@ + New = @' + pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> { + if declared != self.instance() { +'@ + } + @{ + Id = 'S4fold' + Rule = 'Residue S4: the grammar check runs on the declared bytes as given: nothing folds a Unicode hyphen (U+2010) to plain ASCII `-` before asking Slug::validate_slug whether the name is grammatical.' + Test = 'mind::tests::a_unicode_hyphen_is_not_folded_before_the_grammar_check' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + declared.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), + }) + }) +} +'@ + New = @' +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + let folded = Slug(declared.0.chars().map(|c| if c == '\u{2010}' { '-' } else { c }).collect()); + folded.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), + }) + }) +} '@ } @{ @@ -451,6 +533,54 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu } _ => bytes.len() as u64, }, +'@ + } + @{ + Id = 'N4c' + Rule = 'Residue S1: what is measured holds for the operation and the runtime id independently, not merely for the two moving together as N4a and N4b probed them; a formula that adds a term for the operation''s length but drops the runtime id -- `228+id+(id>31)+(op.len()-4)*3/2` -- is wrong once a fixture holds the operation long and the runtime id short.' + Test = 'serve::tests::the_gates_boundary_holds_when_operation_and_runtime_id_vary_independently' + Command = 'cargo test -p huginn-daemon --lib' + File = 'crates/huginn-daemon/src/serve.rs' + Old = @' + let encoded = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) { + Ok(bytes) => bytes.len() as u64, +'@ + New = @' + let encoded = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) { + Ok(bytes) => match &reply { + CultNetMessage::OperationResponse { payload, .. } => { + payload.len() as u64 + + 228 + + message_id.len() as u64 + + (message_id.len() > 31) as u64 + + (operation.len() as u64).saturating_sub(4) * 3 / 2 + } + _ => bytes.len() as u64, + }, +'@ + } + @{ + Id = 'N4d' + Rule = 'Residue S1: the same, the other way round; a formula that adds a term for the runtime id but drops the operation -- `228+id+(id>31)+3*(rt.len()-16)` -- is wrong once a fixture holds the runtime id long and the operation short.' + Test = 'serve::tests::the_gates_boundary_holds_when_operation_and_runtime_id_vary_independently' + Command = 'cargo test -p huginn-daemon --lib' + File = 'crates/huginn-daemon/src/serve.rs' + Old = @' + let encoded = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) { + Ok(bytes) => bytes.len() as u64, +'@ + New = @' + let encoded = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) { + Ok(bytes) => match &reply { + CultNetMessage::OperationResponse { payload, .. } => { + payload.len() as u64 + + 228 + + message_id.len() as u64 + + (message_id.len() > 31) as u64 + + 3 * (runtime_id.len() as u64).saturating_sub(16) + } + _ => bytes.len() as u64, + }, '@ } @{