Skip to content

Move DELETES every satellite under the moved node — comments, threads and access grants are destroyed and the move reports success #3272

Closed

Description

@rbuergi

What happens

Move destroys every satellite under the moved node — comments, threads, access grants,
approvals, activities. They are not moved, not left behind, not reported: they are deleted.

The mechanism is one line of asymmetry in HandleMoveNodeRequest
(src/MeshWeaver.Mesh.Contract/MeshExtensions.cs). A move is copy-to-target + delete-the-source,
and the two legs enumerate the subtree through different surfaces:

  • the copy leg enumerates through the content query, path:{source} scope:subtree. That query
    deliberately excludes satellite paths — StorageAdapterMeshQueryProvider.IsExcludedFromResults
    mirrors PG's per-prefix satellite tables, so a non-satellite content query never returns them.
    So the copy sees no satellites, and CopyNodeRequest.IncludeSatellites = true — which the move
    does pass — filters a set that is already empty;
  • the delete leg enumerates storage.ListDescendantPaths(source), which is documented as "every
    STRICT descendant node path … a native prefix enumeration across every table of the
    partition
    ". Satellites included.

Copy skips them; delete removes them.

Measured — core in-memory mesh, 2026-09-04

MonolithMeshTestBase: a Markdown node with one Comment satellite at {root}/_Comment/c1
(MainNode = {root}), then one MoveNodeRequest:

BEFORE descendants of TestData/sat-1c7e839f: [TestData/sat-1c7e839f/_Comment/c1]
MOVE success=True error=
AFTER  descendants of TestData/satmoved-1c7e839f: []
OLD satellite path still present? False
NEW satellite path present?       False

The move reports success. The comment is gone from both paths.

Why this is serious

The satellite prefixes are where the durable, hard-to-recreate context lives: _Comment,
_Thread, _Approval, _Tracking, _Activity — and _Access, so a move can silently drop the
grants that made a subtree reachable. There is no version history to recover from
(#3264), and the operation reports success, so nothing signals the loss at the time or afterwards.

Blast radius is the whole subtree: every satellite of every descendant, not just the node named in
the request.

Expected

A move relocates satellites with their node, or refuses. It must not delete data the copy leg never
carried.

Note on the fix

The copy leg cannot simply switch to ListDescendantPaths: the content query is the read that
row-level security filters, and enumerating raw storage instead would let a caller copy nodes
they cannot read into a location they control. The satellites have to be enumerated through an
RLS-respecting satellite-targeted read (the IsSatelliteTargetedQuery shape) — which is why this is
filed rather than folded into #3263, whose fix (authorship stamps surviving a move) is independent
and lands separately.

Found while fixing #3263 — same handler, same root cause family: Move is built on the content
query rather than on the store.

Activity

  1. added 2 commits that reference this issue on Sep 4, 2026
  2. rbuergi commented on Sep 4, 2026

    @rbuergi
    ContributorAuthor

    Fixed by #3277, merged into main at 2026-09-04T07:35:59Z as 192a60d84a36cd554eeeb633ea8d9f4f2087caac.

    Closing by hand: core's merge queue does not fire Closes links, so the Closes #3272 in the PR body left this open even though the fix is on main. Verified on origin/main rather than on the PR:

    $ git log --oneline -1 origin/main
    192a60d84 Merge pull request #3277 from Systemorph/fix/3272-move-must-not-orphan-satellites
    $ git show origin/main:src/MeshWeaver.Mesh.Contract/SatelliteTableMapping.cs | grep -c SatelliteContainerOf   → 1
    $ git show origin/main:src/MeshWeaver.Mesh.Contract/CreateNodeRequest.cs     | grep -c RequireComplete        → 4
    $ git show origin/main:test/MeshWeaver.Hosting.Test/MoveCarriesSatellitesTest.cs | grep -c Move_CarriesThe…   → 1
    $ git show origin/main:src/MeshWeaver.Documentation/Data/Architecture/MovingNodes.md | head -3                → the page
    

    Mechanism, as filed and as confirmed

    The issue's diagnosis was right and I confirmed it on both backends rather than assuming it. The copy leg's path:{source} scope:subtree is a primary-table read everywhere:

    • in-memory — StorageAdapterMeshQueryProvider.IsExcludedFromResults strips satellite-path rows from every non-satellite-targeted query, deliberately, to mirror PG's table separation;
    • Postgres — PostgreSqlPartitionedMeshQuery.NeedsFanOut → ResolveTable picks ONE table from the query path's satellite segment or a satellite nodeType filter; path:{main} scope:subtree names neither, so it reads mesh_nodes. That file says it outright: "ListChildPaths only sees mesh_nodes rows, never satellite-table rows."

    So IncludeSatellites = true filtered a set that was already empty, while the delete leg's ListDescendantPaths removed every satellite. Measured before the fix, with the copy leg's own query printed:

    BEFORE storage descendants of TestData/sat-…: [.../_Comment/c1]
    COPY-LEG QUERY 'path:TestData/sat-… scope:subtree' returns: [TestData/sat-…]
    MOVE success=True error=
    AFTER  storage descendants of TestData/satmoved-…: []
    AFTER  storage descendants of TestData/sat-…: []
    

    One correction to the issue's blast radius: Source/Test (→ the code table) were never affected. PostgreSqlStorageAdapter.ResolveQueryTables already unions the CONTENT satellite tables into a primary-table query — added after a Space was GitSync-exported without any of its C# sources. Only the metadata satellites (_Comment, _Thread, _Approval, _Tracking, _Activity, _Access, _Notification, …) were being lost.

    What landed

    (a) The satellites move. The issue's note that the copy leg cannot switch to ListDescendantPaths (RLS) is exactly right, so the two responsibilities are split: storage names which satellite CONTAINERS exist — a path set, never content — and the RLS-filtered query says what may be carried out of them. New SatelliteTableMapping.SatelliteContainerOf(path) cuts a path at the same seam its existing sibling OwnerOfSatellitePath does, and each container is read back as path:{owner}/_Segment scope:subtree limit:all — the one shape every backend resolves to the satellite's own table. One query per container that actually exists (usually none); scope:subtree carries nesting with it; only containers whose owner is a main node the copy is actually carrying are swept.

    (b) …and a move that still cannot carry everything REFUSES. New CopyNodeRequest.RequireComplete, set only by the move, compares the set about to be carried against the same ListDescendantPaths inventory the delete leg is planned from — before anything is created. Nothing written at the target, nothing removed at the source:

    Refused: the copy cannot carry 1 node(s) stored under {source}: {source}/_Comment/c1
    

    That closes the class rather than this instance: an unreadable node, a satellite kind added later, a backend whose query surface diverges again — each becomes a loud refusal instead of a silent deletion. It cannot pass vacuously either: RequireComplete with no IStorageAdapter behind it refuses rather than reporting a completeness it never established.

    🚨 Worth knowing operationally: a move that used to "succeed" destructively can now fail — e.g. a subtree holding something the mover cannot read. That is the intended trade.

    Copy and Delete

    • CopyNodeRequest shared the defect (IncludeSatellites = true on a plain copy carried nothing) and is fixed by the same sweep.
    • DeleteNodeRequest does not — recursive delete already enumerates storage.ListDescendantPaths, so it removes satellites, which is correct for a delete.

    Evidence

    test/MeshWeaver.Hosting.Test/MoveCarriesSatellitesTest.cs — satellites on the moved node, on a descendant, and a nested one; plus the refusal case. Both tests fail with the handler change reverted ("Expected collection to contain an item matching the predicate because the satellite at _Comment/c1 must ride along…", "Expected value to be false because a copy that cannot carry the whole stored subtree must refuse, not report success, but found True") and pass with it. The test prints the copy leg's own query result, so a green run still shows the satellite is absent from it — otherwise a pass could mean "the query started returning satellites" rather than "the sweep works".

    Documented at Doc/Architecture/MovingNodes.

    Interaction with #3263 (which merged first, into the same handler): the two are orthogonal and both are kept — #3263 decides what a re-created node is (every node read from IStorageAdapter, authorship stamps preserved for a move), #3272 decides which nodes there are and whether the set is complete. The satellites this sweep finds go through #3263's Authoritative(...) read too.

    Not fixed here

    A node written between the copy's enumeration and the delete leg's re-enumeration is still deleted uncopied — a pre-existing TOCTOU that the completeness check cannot see, since the delete leg deliberately re-reads storage after the copy. Worth its own issue if it is ever observed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions