From 00fca0cc88fed84b55e90433a480c8d7ce5011a6 Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Thu, 2 Jul 2026 21:14:45 +0530 Subject: [PATCH 1/7] fix(storage): don't tombstone Frozen descendants on subtree delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recursive subtree tombstoning added for #286 (`Index::tombstone_descendants_of`) walked every descendant of a deleted node and tombstoned each one whose `deleted_at >= updated_at`. It never checked `StorageType`, so deleting a non-frozen parent that contains a `Frozen` descendant (e.g. a `FrozenStorage` field) tombstoned the frozen leaf too — violating the rule that Frozen data is immutable and never deleted (enforced elsewhere by the `RemoveMode::Delete` guard in `Interface`). Fix: in the DFS, skip a `Frozen` node AND its whole subtree (no recurse, no tombstone). The frozen data survives as an orphan, which is correct for immutable content-addressed storage. The root is never Frozen here — both delete paths reject that upstream. Adds a regression test that deletes a non-frozen parent holding a Frozen descendant (with its own child) and asserts the frozen node and its subtree survive while the non-frozen rows are tombstoned. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/index.rs | 17 ++++++++++++ crates/storage/src/tests/index.rs | 45 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs index 2330bec862..496503961a 100644 --- a/crates/storage/src/index.rs +++ b/crates/storage/src/index.rs @@ -1003,6 +1003,10 @@ impl Index { /// single-level concurrent add/update-vs-delete semantics applied /// transitively. /// + /// `Frozen` descendants are the one exception: they are immutable and never + /// deleted, so the walk skips a `Frozen` node and its whole subtree, leaving + /// the frozen data as a surviving orphan rather than tombstoning it. + /// /// Internal subtree parent-lists and hashes are intentionally NOT /// recomputed: the whole subtree is detached at the root, so none of it /// feeds a live hash. Only the root's removal from its parent (done by the @@ -1029,6 +1033,19 @@ impl Index { let Some(index) = Self::get_index(id)? else { continue; }; + // Frozen data is immutable and never deleted: skip it AND its subtree + // (don't recurse) so a non-frozen parent's delete leaves the frozen + // descendant as a surviving orphan. Mirrors the `RemoveMode::Delete` + // Frozen guard in `Interface`. The root is never Frozen here (both + // delete paths reject that upstream). + if id != root_id + && matches!( + index.metadata.storage_type, + crate::entities::StorageType::Frozen + ) + { + continue; + } if let Some(children) = &index.children { for child in children { stack.push(child.id()); diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index c3b3010b5a..8be3f1fe2d 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -793,6 +793,51 @@ mod subtree_tombstoning { } assert!(!>::is_deleted(root).unwrap()); } + + /// A non-frozen parent may contain a `Frozen` descendant (e.g. a + /// `FrozenStorage` field). Frozen data is immutable and never deleted, so + /// deleting the parent must SKIP the frozen node AND its subtree (no + /// recursion) while still tombstoning the non-frozen rows. Regression for + /// the #286 cascade, which tombstoned frozen leaves too. + #[test] + fn subtree_delete_skips_frozen_descendant_and_its_subtree() { + use crate::entities::StorageType; + type S = MockedStorage<2104>; + + let mut frozen_md = Metadata::default(); + frozen_md.storage_type = StorageType::Frozen; + + let root = Id::random(); + let a = Id::random(); // non-frozen parent being deleted + let b = Id::random(); // non-frozen descendant -> tombstoned + let f = Id::random(); // Frozen descendant -> survives + let fc = Id::random(); // element under the frozen node -> survives (no recursion) + + >::add_root(ChildInfo::new(root, [1; 32], Metadata::default())).unwrap(); + >::add_child_to(root, ChildInfo::new(a, [2; 32], Metadata::default())).unwrap(); + >::add_child_to(a, ChildInfo::new(b, [3; 32], Metadata::default())).unwrap(); + >::add_child_to(a, ChildInfo::new(f, [4; 32], frozen_md.clone())).unwrap(); + >::add_child_to(f, ChildInfo::new(fc, [5; 32], frozen_md)).unwrap(); + + >::remove_child_from(root, a, time_now()).unwrap(); + + assert!( + >::is_deleted(a).unwrap(), + "deleted parent tombstoned" + ); + assert!( + >::is_deleted(b).unwrap(), + "non-frozen descendant tombstoned" + ); + assert!( + !>::is_deleted(f).unwrap(), + "Frozen descendant must survive the subtree delete" + ); + assert!( + !>::is_deleted(fc).unwrap(), + "walk must not recurse into a Frozen node: its subtree survives too" + ); + } } #[cfg(test)] From fa9ac959227219a346b7d77b555ea7e53c010029 Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Thu, 2 Jul 2026 21:36:46 +0530 Subject: [PATCH 2/7] fix(storage): use struct-init in frozen test (clippy field-reassign) CI clippy runs `--workspace -D warnings`; the mut-default-then-assign pattern tripped `clippy::field_reassign_with_default`. Use `Metadata { storage_type, ..default }`. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/tests/index.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index 8be3f1fe2d..3b3d31be4e 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -804,8 +804,10 @@ mod subtree_tombstoning { use crate::entities::StorageType; type S = MockedStorage<2104>; - let mut frozen_md = Metadata::default(); - frozen_md.storage_type = StorageType::Frozen; + let frozen_md = Metadata { + storage_type: StorageType::Frozen, + ..Metadata::default() + }; let root = Id::random(); let a = Id::random(); // non-frozen parent being deleted From 50769e48b3a02ce6f88c79f1bdc0e5573bb591ec Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Thu, 2 Jul 2026 22:06:42 +0530 Subject: [PATCH 3/7] fix(storage): correct the Frozen-root comment (not a debug_assert) meroreviewer suggested asserting "root is never Frozen". Testing disproved the invariant: the reassign/re-key paths call tombstone_descendants_of with a Frozen root (3 existing tests exercise this), so such an assert panics them in debug/CI. Drop the false claim; document that the Frozen skip is scoped to descendants (`id != root_id`) and a Frozen root is processed normally. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/index.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs index 496503961a..9b22c9d67b 100644 --- a/crates/storage/src/index.rs +++ b/crates/storage/src/index.rs @@ -1033,11 +1033,12 @@ impl Index { let Some(index) = Self::get_index(id)? else { continue; }; - // Frozen data is immutable and never deleted: skip it AND its subtree - // (don't recurse) so a non-frozen parent's delete leaves the frozen - // descendant as a surviving orphan. Mirrors the `RemoveMode::Delete` - // Frozen guard in `Interface`. The root is never Frozen here (both - // delete paths reject that upstream). + // Frozen data is immutable and never deleted: skip a Frozen node AND + // its subtree (don't recurse) so deleting a non-frozen parent leaves + // the frozen descendant as a surviving orphan. Mirrors the + // `RemoveMode::Delete` Frozen guard in `Interface`. Scoped to + // descendants (`id != root_id`): a Frozen root is processed normally, + // since the reassign/re-key callers legitimately pass one. if id != root_id && matches!( index.metadata.storage_type, From 846ae11d9f6141f9bd37f779757a66ec1f1fcfa8 Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Thu, 2 Jul 2026 22:45:49 +0530 Subject: [PATCH 4/7] test(storage): cover non-frozen-under-frozen and frozen-leaf skip cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthens the Frozen-skip coverage: assert the walk skips a Frozen node's WHOLE subtree (a non-frozen child under a Frozen node also survives — the skip is by-subtree, not by-node-type) and that a Frozen leaf (no children) survives. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/tests/index.rs | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index 3b3d31be4e..405e96e016 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -840,6 +840,51 @@ mod subtree_tombstoning { "walk must not recurse into a Frozen node: its subtree survives too" ); } + + /// The skip is by-subtree, not by-node-type: a Frozen node is skipped + /// together with EVERYTHING under it, including a *non-frozen* child. Also + /// covers a Frozen leaf (no children of its own). Complements + /// `subtree_delete_skips_frozen_descendant_and_its_subtree`. + #[test] + fn subtree_delete_skips_whole_frozen_subtree_and_frozen_leaf() { + use crate::entities::StorageType; + type S = MockedStorage<2105>; + + let frozen_md = Metadata { + storage_type: StorageType::Frozen, + ..Metadata::default() + }; + + let root = Id::random(); + let a = Id::random(); // non-frozen parent being deleted + let f = Id::random(); // Frozen node with a non-frozen child below it + let nf_under_f = Id::random(); // NON-frozen, under Frozen -> still survives (no recursion) + let fleaf = Id::random(); // Frozen leaf (no children) -> survives + + >::add_root(ChildInfo::new(root, [1; 32], Metadata::default())).unwrap(); + >::add_child_to(root, ChildInfo::new(a, [2; 32], Metadata::default())).unwrap(); + >::add_child_to(a, ChildInfo::new(f, [3; 32], frozen_md.clone())).unwrap(); + >::add_child_to(f, ChildInfo::new(nf_under_f, [4; 32], Metadata::default())) + .unwrap(); + >::add_child_to(a, ChildInfo::new(fleaf, [5; 32], frozen_md)).unwrap(); + + >::remove_child_from(root, a, time_now()).unwrap(); + + assert!( + >::is_deleted(a).unwrap(), + "deleted parent tombstoned" + ); + assert!(!>::is_deleted(f).unwrap(), "Frozen node survives"); + assert!( + !>::is_deleted(nf_under_f).unwrap(), + "non-frozen child under a Frozen node survives: the walk skips the \ + whole frozen subtree, not just frozen nodes" + ); + assert!( + !>::is_deleted(fleaf).unwrap(), + "Frozen leaf (no children) survives" + ); + } } #[cfg(test)] From 5e7a90d6166deac3ff177484d73909507eabb899 Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Sat, 4 Jul 2026 23:20:17 +0530 Subject: [PATCH 5/7] docs(storage): correct Frozen-guard comment; assert child unlinked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `id != root_id` guard's comment cited "reassign/re-key callers" as passing a Frozen root, but the only two callers (remove_child_from, apply_delete_ref_action) both reject Frozen deletes upstream — the root is never Frozen here. Restate the real invariant. Also assert the deleted node is unlinked from its parent's children list in the frozen-subtree regression test. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/index.rs | 5 +++-- crates/storage/src/tests/index.rs | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs index 9b22c9d67b..1e6012f8f8 100644 --- a/crates/storage/src/index.rs +++ b/crates/storage/src/index.rs @@ -1037,8 +1037,9 @@ impl Index { // its subtree (don't recurse) so deleting a non-frozen parent leaves // the frozen descendant as a surviving orphan. Mirrors the // `RemoveMode::Delete` Frozen guard in `Interface`. Scoped to - // descendants (`id != root_id`): a Frozen root is processed normally, - // since the reassign/re-key callers legitimately pass one. + // descendants (`id != root_id`): both callers (`remove_child_from`, + // `apply_delete_ref_action`) reject deleting Frozen data upstream, so + // the root is never Frozen here; the caller tombstones it regardless. if id != root_id && matches!( index.metadata.storage_type, diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index 405e96e016..177592fe96 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -839,6 +839,13 @@ mod subtree_tombstoning { !>::is_deleted(fc).unwrap(), "walk must not recurse into a Frozen node: its subtree survives too" ); + assert!( + >::get_children_of(root) + .unwrap() + .iter() + .all(|c| c.id() != a), + "deleted node must be removed from parent's children list" + ); } /// The skip is by-subtree, not by-node-type: a Frozen node is skipped From d37bf225eae99356966feec0fbdee7acdeedc76c Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Sat, 4 Jul 2026 23:56:35 +0530 Subject: [PATCH 6/7] feat(storage): reject subtree delete that would strand Frozen data Previously a subtree delete silently skipped a Frozen descendant, leaving it as a graph-detached orphan reachable only by direct Id. Instead, reject the local delete up front: scan the subtree (find_frozen_descendant) and return ActionNotAllowed naming the frozen entity, so the operator relocates it out of the subtree before deleting. Mirrors the existing direct-child Frozen guard and its split-brain avoidance (no DeleteRef is broadcast). The replay path (apply_delete_ref_action) keeps the skip-frozen fallback: a replica can't reject a peer's DeleteRef without diverging, so it preserves the frozen data and converges. Compliant peers never emit such a delete now. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/index.rs | 43 +++++++++++++++- crates/storage/src/interface.rs | 15 ++++++ crates/storage/src/tests/index.rs | 49 +++++++++++++++++++ crates/storage/src/tests/interface.rs | 70 +++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs index 1e6012f8f8..306d2cda67 100644 --- a/crates/storage/src/index.rs +++ b/crates/storage/src/index.rs @@ -1004,8 +1004,12 @@ impl Index { /// transitively. /// /// `Frozen` descendants are the one exception: they are immutable and never - /// deleted, so the walk skips a `Frozen` node and its whole subtree, leaving - /// the frozen data as a surviving orphan rather than tombstoning it. + /// deleted, so the walk skips a `Frozen` node and its whole subtree rather + /// than tombstoning it. A local delete never reaches this state — it is + /// rejected up front by `remove_child_from`'s `find_frozen_descendant` scan. + /// The skip here is the replay-side fallback: on `apply_delete_ref_action` + /// this replica can't reject a peer's `DeleteRef` without diverging, so it + /// preserves the frozen data (leaving it a detached orphan) and converges. /// /// Internal subtree parent-lists and hashes are intentionally NOT /// recomputed: the whole subtree is detached at the root, so none of it @@ -1070,6 +1074,41 @@ impl Index { Ok(()) } + /// Returns the id of the first `Frozen` entity in `root_id`'s subtree + /// (excluding `root_id` itself), or `None` if the subtree holds no frozen + /// data. + /// + /// Read-only pre-check for the local delete guard in + /// [`remove_child_from`](crate::interface::Interface::remove_child_from): a + /// genuine subtree delete must refuse to strand `Frozen` data, so the + /// caller rejects the delete and asks the operator to relocate the frozen + /// entity out of the subtree first. Kept separate from + /// [`tombstone_descendants_of`](Self::tombstone_descendants_of) because the + /// check must complete and reject BEFORE any state is mutated. + pub(crate) fn find_frozen_descendant(root_id: Id) -> Result, StorageError> { + let _mutation_guard = index_mutation_guard(); + let mut stack = vec![root_id]; + while let Some(id) = stack.pop() { + let Some(index) = Self::get_index(id)? else { + continue; + }; + if id != root_id + && matches!( + index.metadata.storage_type, + crate::entities::StorageType::Frozen + ) + { + return Ok(Some(id)); + } + if let Some(children) = &index.children { + for child in children { + stack.push(child.id()); + } + } + } + Ok(None) + } + /// Removes a child reference from a parent without creating a tombstone. /// /// Used when reassigning collection IDs - we need to remove the old child diff --git a/crates/storage/src/interface.rs b/crates/storage/src/interface.rs index a008a3d733..a396e9016a 100644 --- a/crates/storage/src/interface.rs +++ b/crates/storage/src/interface.rs @@ -2500,6 +2500,21 @@ impl Interface { )); } + // A genuine subtree delete must not strand Frozen data buried deeper in + // the tree either. Scan descendants BEFORE mutating any state; if any + // Frozen entity exists, reject so the operator relocates it out of the + // subtree first. Same split-brain avoidance as the direct-child guard + // above: we never tombstone the subtree or broadcast a `DeleteRef` that + // would leave the frozen data detached on every peer. + if mode == RemoveMode::Delete { + if let Some(frozen_id) = >::find_frozen_descendant(child_id)? { + return Err(StorageError::ActionNotAllowed(format!( + "cannot delete subtree {child_id}: it contains Frozen data at {frozen_id}; \ + relocate the frozen entity out of the subtree before deleting" + ))); + } + } + // If this is a local user action, set the nonce if let StorageType::User { owner, .. } = metadata.storage_type { if *owner == crate::env::executor_id() { diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index 177592fe96..d86cddde80 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -892,6 +892,55 @@ mod subtree_tombstoning { "Frozen leaf (no children) survives" ); } + + /// `find_frozen_descendant` powers the local delete guard: it locates a + /// Frozen entity buried anywhere below the delete root (excluding the root + /// itself) and returns `None` for a frozen-free subtree. + #[test] + fn find_frozen_descendant_locates_deep_frozen_and_ignores_root() { + use crate::entities::StorageType; + type S = MockedStorage<2106>; + + let frozen_md = Metadata { + storage_type: StorageType::Frozen, + ..Metadata::default() + }; + + let root = Id::random(); + let a = Id::random(); // delete root (non-frozen) + let b = Id::random(); // non-frozen descendant + let f = Id::random(); // Frozen descendant, two levels down + + >::add_root(ChildInfo::new(root, [1; 32], Metadata::default())).unwrap(); + >::add_child_to(root, ChildInfo::new(a, [2; 32], Metadata::default())).unwrap(); + >::add_child_to(a, ChildInfo::new(b, [3; 32], Metadata::default())).unwrap(); + >::add_child_to(b, ChildInfo::new(f, [4; 32], frozen_md.clone())).unwrap(); + + assert_eq!( + >::find_frozen_descendant(a).unwrap(), + Some(f), + "must find the frozen entity nested below the delete root" + ); + + // A frozen-free subtree returns None. + let c = Id::random(); + >::add_child_to(root, ChildInfo::new(c, [5; 32], Metadata::default())).unwrap(); + assert_eq!( + >::find_frozen_descendant(c).unwrap(), + None, + "a subtree with no frozen data must not be flagged" + ); + + // The root's own StorageType is ignored — only descendants count. + let froot = Id::random(); + >::add_child_to(root, ChildInfo::new(froot, [6; 32], frozen_md)).unwrap(); + assert_eq!( + >::find_frozen_descendant(froot).unwrap(), + None, + "the scan excludes the root itself: a Frozen root with no frozen \ + descendants returns None" + ); + } } #[cfg(test)] diff --git a/crates/storage/src/tests/interface.rs b/crates/storage/src/tests/interface.rs index f6bbeb2ceb..ce713fc464 100644 --- a/crates/storage/src/tests/interface.rs +++ b/crates/storage/src/tests/interface.rs @@ -2018,6 +2018,76 @@ mod frozen_storage_verification { } } + /// Deleting a non-frozen subtree that has a Frozen entity buried below it + /// must also be rejected — the operator has to relocate the frozen data + /// out first. The direct child is NOT frozen here, so this exercises the + /// descendant scan (`find_frozen_descendant`), not the direct-child guard. + #[test] + fn remove_child_from_rejects_subtree_with_frozen_descendant() { + use crate::delta::{commit_causal_delta, reset_delta_context}; + use crate::entities::{ChildInfo, Metadata}; + use crate::index::Index; + use crate::store::MainStorage; + + env::reset_for_testing(); + + // Non-frozen parent under root, with a non-frozen paragraph beneath it. + let mut page = Page::new_from_element("Parent", Element::root()); + assert!(MainInterface::save(&mut page).unwrap()); + let mut para = Paragraph::new_from_element("Middle", Element::new(None)); + assert!(MainInterface::add_child_to(page.id(), &mut para).unwrap()); + + // Attach a Frozen entity two levels down, under the paragraph. + let frozen_id = Id::new([9; 32]); + >::add_child_to( + para.id(), + ChildInfo::new( + frozen_id, + [7; 32], + Metadata { + storage_type: StorageType::Frozen, + ..Metadata::default() + }, + ), + ) + .unwrap(); + + reset_delta_context(); + + // Deleting the paragraph must be refused: its subtree holds frozen data. + match MainInterface::remove_child_from(page.id(), para.id()) { + Err(StorageError::ActionNotAllowed(msg)) => { + assert!( + msg.contains("Frozen") && msg.contains("subtree"), + "error should name the frozen subtree: {msg}" + ); + } + other => panic!("Expected ActionNotAllowed error, got {other:?}"), + } + + // Nothing tombstoned: the paragraph and the frozen node both survive. + assert!( + !>::is_deleted(para.id()).unwrap(), + "paragraph must not be tombstoned by a rejected delete" + ); + assert!( + !>::is_deleted(frozen_id).unwrap(), + "frozen descendant must survive" + ); + + // And no `DeleteRef` was broadcast — the guard fires before mutation. + if let Some(delta) = commit_causal_delta(&[0; 32]).unwrap() { + assert!( + !delta + .actions + .iter() + .any(|a| matches!(a, Action::DeleteRef { id, .. } if *id == para.id())), + "rejected delete must not emit a DeleteRef, got: {:?}", + delta.actions + ); + } + } + #[test] fn frozen_blob_too_small_fails() { env::reset_for_testing(); From 77bb2f978589f1e58214620d50fb33aab95b77f8 Mon Sep 17 00:00:00 2001 From: rtb-12 Date: Sun, 5 Jul 2026 00:04:01 +0530 Subject: [PATCH 7/7] test(storage): lock frozen-skip sync invariant + clarify test layer Assert a skipped frozen node is not listed in its parent's deleted_children advert (else a syncing peer applies a spurious delete-wins against surviving frozen data). Clarify that the Index-level skip test covers tombstone_descendants_of directly, with Interface-level rejection covered in tests/interface.rs. Co-Authored-By: Claude Opus 4.8 --- crates/storage/src/tests/index.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index d86cddde80..992a5df982 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -799,6 +799,12 @@ mod subtree_tombstoning { /// deleting the parent must SKIP the frozen node AND its subtree (no /// recursion) while still tombstoning the non-frozen rows. Regression for /// the #286 cascade, which tombstoned frozen leaves too. + /// + /// This exercises the `tombstone_descendants_of` skip layer directly + /// (`Index`-level). The `Interface`-level behavior — a local delete of such + /// a subtree is REJECTED before any mutation — is covered separately by + /// `remove_child_from_rejects_subtree_with_frozen_descendant` in + /// `tests/interface.rs`. The skip here is the replay-side fallback. #[test] fn subtree_delete_skips_frozen_descendant_and_its_subtree() { use crate::entities::StorageType; @@ -846,6 +852,17 @@ mod subtree_tombstoning { .all(|c| c.id() != a), "deleted node must be removed from parent's children list" ); + // The skipped frozen node must NOT be advertised as deleted on the sync + // wire: it lives on, so listing it in its parent's `deleted_children` + // would make a syncing peer apply a spurious delete-wins against it. + assert!( + !>::get_index(a) + .unwrap() + .unwrap() + .deleted_children() + .contains(&f), + "surviving frozen node must not appear in parent's deleted_children advert" + ); } /// The skip is by-subtree, not by-node-type: a Frozen node is skipped