You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Move DELETES every satellite under the moved node — comments, threads and access grants are destroyed and the move reports success #3272
Movedestroys 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.
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".
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.
Activity
docs: mark the AsynchronousCalls move sketch as a shape, not the cont…
Merge origin/main (#3273's authorship fix) into the satellite fix
rbuergi commentedon Sep 4, 2026
Fixed by #3277, merged into
mainat 2026-09-04T07:35:59Z as192a60d84a36cd554eeeb633ea8d9f4f2087caac.Closing by hand: core's merge queue does not fire
Closeslinks, so theCloses #3272in the PR body left this open even though the fix is onmain. Verified onorigin/mainrather than on the PR: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:subtreeis a primary-table read everywhere:StorageAdapterMeshQueryProvider.IsExcludedFromResultsstrips satellite-path rows from every non-satellite-targeted query, deliberately, to mirror PG's table separation;PostgreSqlPartitionedMeshQuery.NeedsFanOut→ResolveTablepicks ONE table from the query path's satellite segment or a satellitenodeTypefilter;path:{main} scope:subtreenames neither, so it readsmesh_nodes. That file says it outright: "ListChildPaths only sees mesh_nodes rows, never satellite-table rows."So
IncludeSatellites = truefiltered a set that was already empty, while the delete leg'sListDescendantPathsremoved every satellite. Measured before the fix, with the copy leg's own query printed:One correction to the issue's blast radius:
Source/Test(→ thecodetable) were never affected.PostgreSqlStorageAdapter.ResolveQueryTablesalready 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. NewSatelliteTableMapping.SatelliteContainerOf(path)cuts a path at the same seam its existing siblingOwnerOfSatellitePathdoes, and each container is read back aspath:{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:subtreecarries 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 sameListDescendantPathsinventory the delete leg is planned from — before anything is created. Nothing written at the target, nothing removed at the source: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:
RequireCompletewith noIStorageAdapterbehind 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
CopyNodeRequestshared the defect (IncludeSatellites = trueon a plain copy carried nothing) and is fixed by the same sweep.DeleteNodeRequestdoes not — recursive delete already enumeratesstorage.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/c1must 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'sAuthoritative(...)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.