Repairing a Stale MainNode

MainNode is the field that says which node a row belongs to. When it equals the row's own Path, the row is a main node — that is the framework's literal definition, and is:main is SQL n.main_node = n.path. When it differs, the row is a satellite of whatever it names.

A third state exists that is neither, and it is a defect: MainNode naming this node's own id in a partition the node no longer occupies. The row is Active, fully formed, returned by get — and absent from every listing, because it is not a main node by the only test anything applies.

This page is about repairing rows already in that state. The mint sites that produced it are fixed (see How the shape is minted below); this is the other half.

What it looks like

Hosting/Skill/deployment    mainNode → Skill/deployment
Skill/deployment            mainNode → Hosting/Skill/deployment

Both Active, byte-identical instructions, created a week apart. get @Hosting/Skill/deployment returns the node in full. search nodeType:Skill limit:200 does not list it. Neither does path:Hosting/Skill scope:descendants, which returns only the one healthy sibling.

Nothing errors. Nothing logs. No status flips. The only symptom is absence.

Two properties make this defect unusual

1. The broken field is the field that decides who may fix it

Authorization resolves against MainNode, not Path. A node pointing into another partition has its permission check answered by that partition's scope, so the obvious correction is refused:

patch @Hosting/Skill/deployment {"mainNode":"Hosting/Skill/deployment"}
→ Access denied: user 'rbuergi' lacks Update permission on 'Hosting/Skill/deployment'

The corruption protects itself. Here it fails closed, which is the safe direction — but nothing in the mechanism guarantees it always will, and "a node whose MainNode is wrong delegates its own authorization to a partition it does not belong to" is worth reading on its own terms next to Access Control.

The repair therefore runs under RunAsSystem — the sanctioned legitimate infrastructure case from Access Context Propagation. 🚨 RunAsSystem, never Observable.Using(access.ImpersonateAsSystem, …): Rx runs a Using factory on the subscribing thread and disposes it on the terminating one, latching System onto whatever the subscriber does next.

2. Detection cannot use a query

The condition's own definition is "invisible to the index". is:main keeps exactly main_node = path, and Postgres' search_across_schemas hard-filters every union branch on that predicate — unconditionally, not only when the caller asks for is:main.

So a query-driven detector would pass its tests against a permissive in-memory provider and find zero rows on the deployment that actually has the corruption. That is the green-signal-measuring-the-wrong-thing trap in its purest form.

Enumeration runs on IStorageAdapter.ListChildPaths / ListDescendantPaths instead — the authoritative path-routed tree walk the recursive-delete planner is built on. It routes by path, which is intact, so a wrong MainNode cannot hide from it.

Both shapes, and why the detector does not care which

The original report described a mutual cycle: A names B, B names A. That is real, but it is not the whole population — a measurement on 2026-09-01 found that two of the seven were dangling (the named node does not point back), and an eighth node shared the signature and was missing from the report's list entirely.

A repair driven by the reported list would have left that eighth node corrupt; one written for cycles would have skipped the dangling pair or faulted looking for a partner that was not there.

So the repair detects a condition rather than consuming a list. The predicate — MeshExtensions.IsStaleSelfDefaultMainNode — is a per-node shape test:

Both halves are required. Either alone over-reaches: a node may legitimately point MainNode at a parent inside its own partition, and it may legitimately point at another partition under a different id. Both are left untouched.

Because the test is per-node, every affected row is found by construction, a partner is never required to exist, and repairing one end of a cycle never depends on the other — the other is found and repaired on its own merits in the same pass. What the pointer points at is read only to classify the finding for the report, never to decide whether to repair:

Shape Meaning
Cycle The named node exists and points back at this one.
DanglingMissingTarget Nothing exists at the named path.
DanglingUnrelatedTarget The named node exists but names something else.
Unclassified The named node could not be read. Repaired anyway.

🚨 That classification read goes through IStorageAdapter.Read (null-on-absent), never GetMeshNodeStream(target). A point stream read of a node that does not exist terminates the stream with a routing NotFound and opens the storm breaker on that path — and the breaker fast-fails writes too. On a dangling finding, where the target is absent by definition, classifying through the stream would suppress the very repair the pass is there to perform.

One predicate, shared with the forward fix

IsStaleSelfDefaultMainNode is the same method the create and upsert paths apply to every node they write. Two predicates would drift, and the drift would be invisible in exactly the way this defect already is: a repair that skipped a shape the guard refuses would leave rows corrupt with nothing reporting it. Widening the shape widens both halves at once.

The write

access.RunAsSystem(() => hub.GetMeshNodeStream(path)
    .Update(current => current with { MainNode = current.Path }))

GetMeshNodeStream(path).Update(…) is not merely the preferred route, it is the only one that can express this correction. A full-instance upsert can move a MainNode anywhere except back onto the node's own path, because that intent is indistinguishable from the untouched default — MainNode is non-nullable and self-defaults, so "the writer never touched it" and "the writer set it to this node" are the same value on the wire. See MeshNode.HasExplicitMainNode, and the mutation API.

The idempotence contract

The repair writes the one value the predicate cannot match: after MainNode = Path, HasExplicitMainNode reads false and IsStaleSelfDefaultMainNode returns false at the first test.

That is the whole mechanism, and it matters that it is a property of the written value rather than a ledger:

Reporting: evidence, not a count

Every finding carries the path, the pointer as found, the classified shape, and the outcome (including the error message when a write failed). A failed node does not stop the rest, and it comes back named rather than as a swallowed exception, so a run against a live portal produces something a maintainer can act on.

Every pass ends with one summary line whether or not it found anything — "the repair never considered the row" and "the repair found nothing" must not look the same.

Findings come back in path order, and that is a contract rather than an accident. IStorageAdapter.ReadMany states outright that its order is not guaranteed — the default implementation merges N reads, and Postgres answers one batched IN (…) in whatever order the planner likes — so the candidates are sorted at the stage that loses the order, which makes classification, the per-node log lines and the report all follow the same sequence.

It matters because this repair's deliverable is its evidence. A run against live data is read by a person deciding whether it did the right thing, so the report has to be diffable between the Detect dry run and the real run, and lineable-up against an expected set. A stable order is also what lets a second run's report be compared against the first — which is how the idempotence claim gets checked rather than merely asserted.

Using it

// Measurement. Writes nothing. Safe on production.
StaleMainNodeRepair.Detect(hub).Subscribe(r => …);

// The repair. Scope it to partitions, or omit for the whole mesh.
StaleMainNodeRepair.Repair(hub, ["Hosting", "Store"]).Subscribe(r => …);

Both return cold observables — the sweep runs on Subscribe, not on the call.

🚨 Nothing self-arms. This is a static one-shot pipeline, not an IHostedService, and nothing in the composition root calls it: deploying an image containing it runs no repair. Running it against a live portal is a separate, deliberate decision, and Detect exists so that decision can be taken on measured evidence first.

This is the same shape AssemblyCacheRetentionHostedService uses for its own destructive sweep — report by default, act only when explicitly armed — and it is the reason the raw psql UPDATE alternative is not on the table: a direct SQL write bypasses the workspace cache, so live subscriptions keep serving the old value until the portal restarts. See Postgres Schema Architecture.

The unresolved duplicate-copy question

The repair deliberately does not answer it, and deletes nothing.

A cycle is two Active copies of one node — Skill/deployment beside Hosting/Skill/deployment — with identical content, created a week apart. Re-stamping both makes both visible, which is correct as far as pointers go and leaves the real question open:

Why does a package skill have a second Active copy in the platform Skill/ partition at all?

That is likely the mechanism that produced the cycle in the first place — two writers each rebasing the other's half — so repairing pointers without answering it invites a recurrence. But deciding which copy is canonical, and whether the other should be deleted, is a judgement about content, not a mechanical one. The repair re-stamps MainNode = Path on both ends and leaves both copies in place.

A Detect pass is the input to that decision: its Cycle findings are exactly the duplicate pairs.

How the shape is minted

MainNode is a stored property; Path and Segments are computed. So a plain with { Namespace = … } moves the path and leaves MainNode naming the partition the node was born in — and because the field is non-nullable, that stale value is indistinguishable on the wire from a deliberate satellite pointer, so every upsert faithfully persists it.

The fix at the mint sites is MeshNode.WithPath(id, ns), which rebases the pointer when it was the self-default and carries a deliberate one across untouched. The create and upsert paths additionally re-stamp anything that arrives with the shape, so no new row can acquire it.

Neither of those touches a row already stored: the defect moved out of the code and into the data, and only a pass over the data closes it. SelfTypedDeclarationDurableRepair exists for the same reason and draws the same distinction — a write guard can only ever refuse the next bad row.

🚨 Restoring MainNode == Path is necessary but not sufficient for a decentral node to be searchable again. A second, independent defect produces the identical symptom: a query union whose legacy single Query field carries only list[0], so a static node matched by the second query is silently absent. Both halves are required; neither alone restores visibility.

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