From d4c6d07ef1deeba7da2b0b671c8b4c97717c4b5f Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 03:39:54 +0000 Subject: [PATCH] cultcache-rs: is_writable asks the home store; refusal names the real exits; mesh divergence stated; load_soa same-length pin Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/CHANGELOG.md | 4 +-- packages/cultcache-rs/src/lib.rs | 4 +-- packages/cultcache-rs/tests/slot_readonly.rs | 35 ++++++++++++++++++-- packages/cultmesh-rs/src/lib.rs | 7 ++-- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 71fc4129..c2694417 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -10,8 +10,8 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). -- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (repair it with the build that wrote it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. -- cultmesh-rs `pull_rudp_catalog_snapshot` applies documents with `put_raw_envelope`, so a snapshot document for a key the node holds read-only ends the pull at that document (documents before it stay applied), as C# CultMesh, which holds no per-key handling either. Per-key isolation is follow-up `mesh-unread-slots`. +- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. +- cultmesh-rs `pull_rudp_catalog_snapshot` applies documents one by one with `put_raw_envelope`, so a snapshot document for a key the node holds read-only ends the pull at that document and the documents before it stay applied. C# applies a raw snapshot as one cache commit, so there a refusal applies nothing: this is a divergence, owed by follow-up `mesh-unread-slots` (stage the pull through `put_prepared_batch`). - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 648bd1d0..59ffa1af 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -3215,7 +3215,7 @@ impl CultCache { stored_at: String::new(), schema_id: None, }; - Ok(self.refuse_retype(&self.entries, &self.foreign, &probe).is_ok()) + Ok(self.home_index(type_id).is_ok() && self.refuse_retype(&self.entries, &self.foreign, &probe).is_ok()) } /// The one decode behind every write entry and [`CultCache::is_writable`]: the held envelope at `(type_id, key)` @@ -3240,7 +3240,7 @@ impl CultCache { match self.held_finding(type_id, key)? { HeldFinding::Free => Ok(()), HeldFinding::Unreadable(error) => Err(error.context(format!( - "CultCache entry {type_id:?} at key {key:?} is held read-only: this build cannot read its payload; repair it with the build that wrote it" + "CultCache entry {type_id:?} at key {key:?} is held read-only: this build cannot read its payload. A newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, not through the cache" ))), HeldFinding::Marked(unread) => { let held = &self.entries[&entry_id_parts(type_id, key)]; diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs index cc2e1847..5bffcd9a 100644 --- a/packages/cultcache-rs/tests/slot_readonly.rs +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -346,7 +346,7 @@ fn a_held_payload_the_type_cannot_read_is_read_only_and_refused_by_name() { let mut cache = registered(); // A newer writer's record: an arm this build lacks and an extra root slot. get cannot read it, and it is the data // read-only exists to protect. - let newer = rmp_serde::to_vec(&("newer", (99u64, ("x",)), "extra")).unwrap(); + let newer = rmp_serde::to_vec(&("CANARY-name", (99u64, ("x",)), "CANARY-extra")).unwrap(); cache.put_raw_envelope(slot_unit_envelope("n", newer)).unwrap(); assert!(cache.get::("n").is_err()); assert!(!cache.is_writable(SlotUnit::TYPE, "n").unwrap()); @@ -359,7 +359,7 @@ fn a_held_payload_the_type_cannot_read_is_read_only_and_refused_by_name() { ] { let message = format!("{error:#}"); assert!(message.contains("\"n\"") && message.contains("tests.slot_unit") && message.contains("read-only"), "{message}"); - assert!(!message.contains("newer") && !message.contains("extra"), "{message}"); + assert!(!message.contains("CANARY"), "{message}"); } assert_eq!(cache.snapshot_envelopes(), held); } @@ -371,7 +371,8 @@ fn a_corrupt_held_payload_is_refused_naming_the_key_never_its_bytes() { assert!(!cache.is_writable(UnionSurface::TYPE, "broken").unwrap()); let error = cache.delete::("broken").unwrap_err(); let message = format!("{error:#}"); - assert!(message.contains("\"broken\"") && message.contains("repair"), "{message}"); + assert!(message.contains("\"broken\"") && message.contains("backup") && message.contains("store"), "{message}"); + assert!(!message.contains("build that wrote it"), "{message}"); assert!(!message.contains("CANARY"), "{message}"); assert!(cache.is_writable("tests.unregistered", "broken").is_err()); } @@ -462,3 +463,31 @@ fn a_marked_decode_leaves_no_scope_open() { let _ = decode_payload_marked::(&vector("depth-skipped-past-limit.msgpack")); assert_eq!(cultcache_rs::__enter_slots(), 0); } + +#[test] +fn is_writable_is_false_for_a_type_with_no_home_store() { + let dir = tempfile::tempdir().unwrap(); + let mut cache = registered(); + // One store, routed to SlotUnit only: UnionSurface has no home. + cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap(); + assert!(cache.put("k", &constructed()).is_err()); + assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap()); + assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap()); +} + +#[test] +fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() { + use cultcache_rs::CultCacheSoaTable; + let (mut cache, _dir, _) = held_marked_and_clean(); + let all = cache.snapshot_envelopes(); + let marked = all.iter().find(|held| held.key == "marked").unwrap(); + // The clean record's bytes padded to the marked record's length: same length, different bytes. + let mut same_length = vec![0xc0; marked.payload.len()]; + same_length[..2].copy_from_slice(&[0x91, 0xa1]); + assert_eq!(same_length.len(), marked.payload.len()); + let mut replaced = all.clone(); + replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = same_length; + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(replaced)).unwrap_err(); + assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1)); + assert_eq!(cache.snapshot_envelopes(), all); +} diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 91e03bd2..c84e25a7 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -737,8 +737,9 @@ impl CultMeshNode { /// Applies each snapshot document of a registered schema with `put_raw_envelope`. The first refusal ends the pull /// and the documents before it stay applied; a key this node holds read-only (unread slots from a newer peer, or a - /// payload this build cannot read) is refused like any other, as C# CultMesh holds no per-key handling either - /// (follow-up mesh-unread-slots). + /// payload this build cannot read) is refused like any other. C# applies a raw snapshot as one cache commit, so a + /// refusal there applies nothing: this is a divergence, owed by follow-up mesh-unread-slots (stage the pull through + /// `put_prepared_batch`). fn apply_raw_snapshot_documents( &mut self, documents: Vec, @@ -1225,7 +1226,7 @@ mod tests { } #[test] - fn a_snapshot_document_for_a_read_only_key_ends_the_pull_and_keeps_the_applied_ones() -> Result<()> { + fn a_snapshot_document_for_a_read_only_key_ends_the_pull_unlike_csharp_which_applies_nothing() -> Result<()> { let temp = tempfile::tempdir()?; let mut node = CultMesh::create_node(temp.path().join("m.cc"), TestDocuments, Default::default())?; // A newer peer's note: one slot past the declared one.