Skip to content

Move re-stamps createdBy/createdDate/lastModifiedBy on the moved node and its whole subtree — a relocation is recorded as a creation, destroying authorship #3263

Closed

Description

@rbuergi

What happens

Move (menu item, and the move MCP op) re-stamps the moved node's authorship and creation metadata as if the node had just been created by the person doing the move. createdDate, createdBy, lastModified and lastModifiedBy are all overwritten; only version survives.

This contradicts the field's own contract in MeshNode:

createdDate — "Timestamp when this node was first created. Set once at creation time; never updated thereafter."
createdBy — "Identity … that created this node. Stamped at creation time and never changed."

A move is a relocation. It is not a creation and not a content edit, so it should preserve all four stamps.

Measured — memex.systemorph.com, 2026-09-03

ClientA/EslProposal/Pricing was authored by a colleague and last edited by them on 2026-09-01. It was moved (with its parent subtree) to ClientA/Esl/EslProposal/Pricing at 20:15:43Z. Reading it back immediately after:

{
  "path": "ClientA/Esl/EslProposal/Pricing",
  "createdDate":    "2026-09-03T20:15:43.521686+00:00",   // ← the move
  "createdBy":      "rbuergi",                             // ← whoever ran the move
  "lastModified":   "2026-09-03T20:15:43.491882+00:00",   // ← the move
  "lastModifiedBy": "rbuergi",                             // ← whoever ran the move
  "version": 47                                            // ← preserved
}

The node now claims it was created today by the person who moved it. The real author and the real creation date are gone from the node.

version: 47 surviving is the tell: this is not a copy-and-delete losing identity, it is the same row with its stamps rewritten.

Why this is serious rather than cosmetic

Nothing in the product recovers the lost values, because there is no version history to fall back on (filed separately — the two together are what make this unrecoverable rather than merely annoying). So a single subtree move silently and permanently rewrites the authorship of every node under it.

Blast radius scales with the subtree: one restructure of a client space rewrote the stamps on ~80 nodes in one action, including decks, slides and a commercial proposal, all of which now read as created that minute by the person who ran the move. After the fact there is no way to tell who actually wrote any of them, or to distinguish a node that was merely relocated from one that was genuinely authored that day.

Expected

Move preserves createdDate, createdBy, lastModified and lastModifiedBy on the moved node and on every descendant. If a move should be recorded at all, it belongs in the activity log or as its own history entry — not by overwriting who wrote the thing.

Repro

  1. Create a node as user A; note its createdBy / createdDate / lastModifiedBy.
  2. As user B, move it (or an ancestor of it) to another path.
  3. get the node at its new path — all four stamps now name user B and the moment of the move.

Applies to the whole subtree, not just the node named in the move.

Activity

  1. rbuergi commented on Sep 3, 2026

    @rbuergi
    ContributorAuthor

    Cross-reference: #3264 — no version history is retained on this portal, so the stamps this bug overwrites cannot be recovered afterwards. Independent root causes; together they turn a Move into permanent loss of authorship.

  2. rbuergi commented on Sep 4, 2026

    @rbuergi
    ContributorAuthor

    Fixed on main by #3273 (merged 2026-09-04). Closing by hand — core's merge queue does not fire Closes links; six issues behaved that way today.

    🚨 Verified by reading the merged source, and existence-checked first — earlier today I closed three issues on a git show <ref>:<path> | grep … || echo clean that printed "clean" because the path did not exist. So: git cat-file -e origin/main:src/MeshWeaver.Mesh.Contract/MeshExtensions.cs → exists, then read it.

    The mechanism was TWO halves, and the issue's "Expected" only named the symptom

    Half 1 — the copy leg re-stamped unconditionally. Move is copy-to-target + delete-the-source, and RetargetNode stamped LastModified = UtcNow on every copied node — so the moved node and every descendant got it.

    Half 2 — and this is the one nobody could have guessed from the issue. The copy leg re-created its nodes from meshService.Query<MeshNode>("path:{source} scope:subtree") — a projection, not the stored node. PostgreSqlSqlGenerator.GenerateSelectQuery selects id, namespace, name, node_type, description, category, icon, display_order, last_modified, version, state, content, desired_id, main_node, sync_behavior, exclude_from_context — and not created_by, created_date, last_modified_by. Those are real columns the point read and the INSERT both use, but no query projects them, and PgMeshNodeReader returns null for an absent column by design. So on Postgres every node arrived with blank stamps and HandleCreateNodeRequest filled them with "now, by the caller".

    That is why version: 47 survived while the four stamps did not — it confirms the row is the same row, and it also explains why this could never be reproduced on the in-memory provider, which returns the stored node verbatim. Filed separately as Systemorph/MeshWeaver.Plugins#1310.

    What landed

    RetargetNode now takes preserveAuthorship, and a move passes true:

    return preserveAuthorship
        ? relocated                       // Id / Namespace / MainNode change; the four stamps ride along
        : relocated with { CreatedDate = default, CreatedBy = null,
                           LastModified = default, LastModifiedBy = null };

    and the copy leg reads each node from IStorageAdapter rather than trusting the query projection — the query stays the RLS-filtered decision of which nodes, storage answers what they are. No values are written back after the fact.

    Copy deliberately does the opposite, and that distinction is the reason this is a flag rather than a blanket rule: AccessContextScope impersonates MeshNode.CreatedBy, so a copy that inherited the original creator would run owner-scoped work as someone who never touched the node.

    A privilege-escalation hole found and closed while gating this

    Measured, not theorised: with the flag ungated, an Editor (Read | Create | Update, no Delete) could copy an admin's node with authorship intact — minting a node in their own space that the platform then impersonates the admin for. RLS does not close it, because reading the source is exactly what an Editor may do. PreserveAuthorship now requires Delete on the source namespace — the same entitlement MoveNodePermissionAttribute requires of a mover — with UNDETERMINED refusing as unavailability rather than denial.

    Test

    test/MeshWeaver.Hosting.Test/MoveNodePreservesAuthorshipTest.cs, over the real MonolithMeshTestBase, asserting the moved node and a descendant. Without the fix it failed on the timestamps (lastModified …39.3262830Z → …39.4102920Z on the root, …39.3278600Z → …39.4127360Z on the descendant); with it, 2/2. The copy control arm fails on main without the change too.

    Honest limit recorded in the PR: the projection half is not falsifiable by a core test, because the in-memory provider returns the stored node verbatim. Its evidence is the Postgres SELECT list.

    #3264 (no version history retained) remains the reason this was unrecoverable rather than merely wrong — the reporting half of that is #3278; the store itself is still open.

  3. added a commit that references this issue on Sep 4, 2026
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