diff --git a/epiphany-core/src/reasoning_context.rs b/epiphany-core/src/reasoning_context.rs index a181e35b..11f5a8a4 100644 --- a/epiphany-core/src/reasoning_context.rs +++ b/epiphany-core/src/reasoning_context.rs @@ -2564,6 +2564,62 @@ mod tests { Ok(()) } + /// Every write in the batch is validated, not just the first. A batch of + /// two distinct writes whose second is refused is refused whole, with + /// nothing written. + #[test] + fn mind_commit_validates_every_write_in_the_batch() -> Result<()> { + let temp = tempdir()?; + let store = temp.path().join("mind.cc"); + initialize_runtime_spine( + &store, + RuntimeSpineInitOptions { + runtime_id: "mind-batch-validation-test".into(), + display_name: "Mind batch validation test".into(), + created_at: "2026-09-15T00:00:00Z".into(), + }, + )?; + let value = crate::EpiphanyObservation { + id: "first".into(), + summary: "one".into(), + source_kind: "test".into(), + status: "accepted".into(), + code_refs: Vec::new(), + evidence_ids: Vec::new(), + }; + let accepted = runtime_spine_cache(&store)? + .prepare_entry("first", &crate::EpiphanyMindObservationDocument { value })? + .0; + let refused = refused_mind_write(&store)?; + assert_ne!( + (&accepted.r#type, &accepted.key), + (&refused.r#type, &refused.key), + "the two writes carry distinct identities, so uniqueness cannot answer first" + ); + let provenance = EpiphanyMindDocumentVersion::from_envelope("epiphany-organ", &accepted)?; + let before = std::fs::read(&store)?; + let error = commit_external_typed_observation_mind_mutation( + &store, + "test-organ", + provenance, + "test-owner", + Vec::new(), + vec![accepted, refused], + "2026-09-15T00:00:01Z", + ) + .unwrap_err(); + assert!( + error.to_string().contains("semantic identity"), + "the second write is validated too: {error}" + ); + assert_eq!( + std::fs::read(&store)?, + before, + "a refused batch writes nothing, including its valid writes" + ); + Ok(()) + } + /// Ruling 12 again: identity uniqueness is checked before write validation, /// so a batch that repeats an identity reports the repeat, not the /// validator's refusal. The order is fail-closed either way; this pins @@ -2605,6 +2661,12 @@ mod tests { /// the cache load, the batch CAS and the conflict re-read. A commit that /// wrote anywhere else would leave a second store beside this one, and a /// replay read from anywhere else would not find the receipt. + /// + /// The file assertions below cannot see a handle re-derived from the *same* + /// path, because a handle carries nothing but its path. The resolution + /// count can, and that is the shape the map's redb plan makes load-bearing: + /// redb permits one writable handle per path, so a second derivation is a + /// second handle there. #[test] fn mind_commit_reads_and_writes_one_store() -> Result<()> { let temp = tempdir()?; @@ -2640,9 +2702,16 @@ mod tests { at, ) }; + let resolutions_before = + crate::runtime_store_backend::backing_store_resolutions(&store); let EpiphanyMindCommitOutcome::Committed(receipt) = commit("2026-09-15T00:00:01Z")? else { panic!("the first Mind write must commit"); }; + assert_eq!( + crate::runtime_store_backend::backing_store_resolutions(&store) - resolutions_before, + 1, + "the commit resolves the backing store once and reuses that handle" + ); let mut files = std::fs::read_dir(temp.path())? .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())) .collect::>>()?; @@ -2725,8 +2794,8 @@ mod tests { initialize_runtime_spine( &store, RuntimeSpineInitOptions { - runtime_id: "mind-profile-test".into(), - display_name: "Mind profile test".into(), + runtime_id: "mind-commit-keeps-test".into(), + display_name: "Mind commit keeps test".into(), created_at: "2026-09-15T00:00:00Z".into(), }, )?; diff --git a/epiphany-core/src/runtime_store_backend.rs b/epiphany-core/src/runtime_store_backend.rs index 4f0add24..42310a2a 100644 --- a/epiphany-core/src/runtime_store_backend.rs +++ b/epiphany-core/src/runtime_store_backend.rs @@ -108,7 +108,11 @@ impl CacheBackingStore for RuntimeSpineBackingStore { pub(crate) fn runtime_spine_backing_store(path: &Path) -> Result { match path.extension().and_then(|extension| extension.to_str()) { - Some("cc" | "msgpack" | "redb") => Ok(RuntimeSpineBackingStore::new(path)), + Some("cc" | "msgpack" | "redb") => { + #[cfg(test)] + record_backing_store_resolution(path); + Ok(RuntimeSpineBackingStore::new(path)) + } extension => Err(anyhow!( "runtime spine store must use an explicit .cc, .msgpack, or .redb extension; got {:?}", extension @@ -116,6 +120,39 @@ pub(crate) fn runtime_spine_backing_store(path: &Path) -> Result> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn record_backing_store_resolution(path: &Path) { + RESOLVED_BACKING_STORES + .lock() + .expect("the backing-store resolution log is never poisoned") + .push(path.to_path_buf()); +} + +/// How many times this path has been resolved into a handle. Tests run in +/// parallel, so a caller compares this around its own call against its own +/// temporary path rather than reading a global total. +#[cfg(test)] +pub(crate) fn backing_store_resolutions(path: &Path) -> usize { + RESOLVED_BACKING_STORES + .lock() + .expect("the backing-store resolution log is never poisoned") + .iter() + .filter(|resolved| resolved.as_path() == path) + .count() +} + #[cfg(test)] mod tests { use super::*; diff --git a/tools/eureka-cut5-mutations.ps1 b/tools/eureka-cut5-mutations.ps1 index 46877053..125aa5fa 100644 --- a/tools/eureka-cut5-mutations.ps1 +++ b/tools/eureka-cut5-mutations.ps1 @@ -17,8 +17,12 @@ # Requires CARGO_TARGET_DIR to be set the way the cut ran it, or it will build # into the repo-local target/. # +# M1-M5 are the collapse's own rules. MS1 and MS2 close Soul's findings S7 and +# S8: each is a mutation the suite used to survive, kept here so the gap cannot +# reopen. +# # This suite differs from Cut 4's in one way: an entry carries a list of edits -# rather than a single Old/New pair, because two of the five rules are about +# rather than a single Old/New pair, because two of the seven rules are about # the *order* of two statements. Removing a statement and reinserting it # elsewhere is two edits and cannot be expressed as one replacement. Every edit # is still asserted to match exactly once. @@ -56,7 +60,7 @@ $mutations = @( }, @{ Id = 'M2' - Rule = 'Every write in the batch is validated against the Mind document rules.' + Rule = 'The batch is validated against the Mind document rules at all. MS2 pins that every write is.' Test = 'reasoning_context::tests::mind_commit_keeps_validation_receipts_replay_and_conflicts' Edits = @( @{ @@ -132,6 +136,43 @@ $mutations = @( for write in &writes { crate::mind_documents::validate_mind_write_envelope(write)?; } +'@ + } + ) + }, + # Soul's finding S7. M3 sends the CAS to a *different* path, which leaves a + # second file the one-store test can see. This sends it to the same path, + # which leaves no trace in the directory at all and survived M3's test. It is + # still a second handle, and the map's redb plan makes that fatal: redb + # permits one writable handle per path. + @{ + Id = 'MS1' + Rule = 'The owner resolves the backing store once and reuses that handle, rather than re-deriving it.' + Test = 'reasoning_context::tests::mind_commit_reads_and_writes_one_store' + Edits = @( + @{ + File = $context + Old = ' if backing_store.compare_and_swap_batch(&expected, replacements)? {' + New = ' if runtime_spine_backing_store(store_path)?.compare_and_swap_batch(&expected, replacements)? {' + } + ) + }, + # Soul's finding S8. M2 neuters validation for every write, which any + # single-write batch catches. This keeps validation and narrows it to the + # first write, which nothing caught until a batch of two carried an invalid + # second write. + @{ + Id = 'MS2' + Rule = 'Every write in the batch is validated, not just the first.' + Test = 'reasoning_context::tests::mind_commit_validates_every_write_in_the_batch' + Edits = @( + @{ + File = $context + Old = $validationLoop + New = @' + for write in writes.iter().take(1) { + crate::mind_documents::validate_mind_write_envelope(write)?; + } '@ } )