MainNode and Rebasing

Every mesh node carries a MainNode. For an ordinary node it names itself; for a satellite (a comment, an approval, an access grant) it names the primary node the satellite belongs to. The catalog turns that one field into the difference between listed and not listed:

-- what `is:main` means, in the catalog's own SQL
WHERE n.main_node = n.path

So a node whose MainNode drifts off its own Path disappears from every search, every listing, every scope:subtree sweep — while get still returns it, state: Active, fully formed, with nothing logged and no status flipped. That failure mode is the whole reason this page exists.

The trap: a stored property with a constructor-time default

MeshNode is a record. Two of its members look alike and behave nothing alike:

public record MeshNode(string Id, string? Namespace = null)
{
    // COMPUTED — re-evaluated on every read, so it follows a `with` copy.
    public string Path => string.IsNullOrEmpty(Namespace) ? Id : $"{Namespace}/{Id}";

    // STORED — the initializer runs ONCE, at construction, and a `with` copy carries the value over.
    public string MainNode { get; init; } = string.IsNullOrEmpty(Namespace) ? Id : $"{Namespace}/{Id}";
}

Rebase such a node with a plain record copy and the two diverge:

var minted  = new MeshNode("deployment", "Skill");          // MainNode = "Skill/deployment"
var rebased = minted with { Namespace = "Hosting/Skill" };  // Path     = "Hosting/Skill/deployment"
                                                            // MainNode = "Skill/deployment"  ← stale

Path moved. MainNode did not. The node is now un-listable.

Why nothing downstream can repair it

MainNode is non-nullable. On the wire, "the writer never touched it" and "the writer set it to this node itself" are the same bytes. Every merge that has to decide whether an incoming node means to move a stored MainNode therefore asks MeshNode.HasExplicitMainNodedoes it name something other than this node's own path? — because a null check cannot express the question.

For a stale rebase that predicate answers true: Skill/deployment really is "something other than Hosting/Skill/deployment". The stale default is indistinguishable, by shape alone, from a deliberate satellite pointer, so every writer downstream faithfully persists it.

And the same non-nullability caps the repair from the other side: a full-instance upsert can move a MainNode anywhere except back onto the node's own path, because that intent is exactly what reads as untouched. The one route that can restore a main node is a merge patch, which can see the key was PRESENT:

workspace.GetMeshNodeStream(path).Update(n => n with { MainNode = n.Path })

The rule

Never rebase a node with with { Namespace = … } or with { Id = …, Namespace = … }. Use MeshNode.WithPath(id, ns) / MeshNode.WithNamespace(ns).

WithPath moves Path and MainNode together, and preserves a MainNode the writer set deliberately:

new MeshNode("deployment", "Skill").WithNamespace("Hosting/Skill");
//   Path = MainNode = "Hosting/Skill/deployment"

MeshNode.Satellite("_Policy", "Teams").WithNamespace("Space/Teams");
//   Path = "Space/Teams/_Policy", MainNode = "Teams"   — explicit, so untouched

Two corollaries worth stating:

The repair guard, and why its trigger is narrow

HandleCreateNodeRequest step 1b′ re-stamps a stale self-default before it is ever stored, and CreateOrUpdateNodeRequest runs the same repair on the merged node so a re-import heals a row that is already corrupted. One helper, three call sites — it was pasted twice before, and the copies were already drifting.

The trigger is deliberately the exact bug shape, not a blanket MainNode != Path:

Shape Repaired? Why
MainNode == Id on a namespaced node Built bare, namespaced later. The bare value routed a thread into a phantom partition — Postgres 42P01.
MainNode ends with /{Id} and names a different first segment (partition) A self-default frozen in the namespace the node was born in.
MainNode names a parent inside the node's own partition Legitimate: GitHubSyncConfig's MainNode = spacePath; a ~/Threads app tile targeting {owner}/Threads.
MainNode names another partition under a different id Legitimate: an app tile targeting Store/Foo with id Store-Foo.
Any satellite node type Handled by step 1b, which points it at its owner.

Both halves of the second row are load-bearing. Either one alone over-reaches, and an over-reaching repair is the reverse defect: it promotes a satellite to a main node, which puts it back in its owner's listings and re-scopes its grants (they project at COALESCE(main_node, namespace)).

What this cost, twice

Both were latent for days behind a green wall. A field that cannot be observed to be wrong needs a guard, not care — which is why the rebase now lives in one method and the repair in one helper.

🚨 One symptom, two independent defects

Restoring MainNode == Path does not by itself make a decentral node searchable. A second and unrelated defect produces the same symptom, and fixing either alone leaves the skills broken:

MeshQueryRequest.FromQueries builds a union but fills the legacy single Query field with list[0]. StaticNodeQueryProvider reads Query; StorageAdapterMeshQueryProvider iterates EffectiveQueries. A static node matched only by query #2 is therefore silently absent — and the skill query set leads with the platform catalog and follows with the package partition, i.e. exactly query #2.

That is #2942, tracked separately. Both halves are required. This is worth stating plainly because the failure mode is shared: two different causes, one indistinguishable symptom (get returns it, search does not), and a fix for either that looks complete on its own.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.