Repository navigation
A rebased node keeps its main-node pointer (#2939) - #2949
Merged
Merged
Conversation
…ndable
`MeshNode.MainNode` is a STORED, non-nullable init property whose default is
evaluated once, at construction. `Path` is computed, so a `with { Namespace = … }`
copy moves the path and leaves `MainNode` naming the namespace the node was BORN
in — and because the field can never be null, that leftover is indistinguishable
on the wire from a deliberate satellite pointer, so every writer downstream
persists it. The node then drops out of `is:main` (SQL `n.main_node = n.path`)
while `get` still returns it, Active and fully formed, with nothing logged.
Three changes:
1. **One rebase API.** `MeshNode.WithPath(id, ns)` / `WithNamespace(ns)` move the
path and `MainNode` together and preserve an AUTHORED `MainNode`
(`HasExplicitMainNode`). `PackageInstaller.ParseCanonical` now uses it — its
`MainNode = parsed.MainNode ?? (…)` was DEAD CODE (the field is non-nullable,
so the right-hand side never ran) and the intent it states cannot be expressed
with a null check. The install path writes through
`persistence.WriteManyAndPublishCreated`, bypassing the create-request guard
entirely, so this line is the only defence there.
2. **One repair guard, and it covers updates.** The 1b′ repair was pasted into the
singular and the batch create paths; it is now one helper
(`RepairStaleSelfDefaultMainNode`) used by both plus `CreateOrUpdateNodeRequest`,
on the MERGED node — a full-instance upsert provably cannot move a `MainNode`
back onto the node's own path, so a re-import used to compare equal, skip as a
no-op, and leave the row invisible forever. `IsNoOpUpsert` now knows that too.
The trigger stays narrow: MainNode's LAST SEGMENT is this node's own id AND it
names a different partition. Either half alone over-reaches (a `~/Threads` tile
targeting `{owner}/Threads`; a tile targeting `Store/Foo` with id `Store-Foo`).
3. **The other rebase sites.** `EmbeddedResourceStorageAdapter`,
`DocumentationNodeProvider`, `MeshOperations.NormalizeNamespace` and
`SanitizeNodeId` all carried the same defect and now use `WithPath`.
Doc: Doc/Architecture/MainNodeRebasing — the trap, the rule, the guard's trigger
table, and the two incidents (#2383, #2939) it has caused.
Pairs with Systemorph/MeshWeaver.Plugins#1053, which fixes the origin: the skill
importer minted every `.md` skill in the platform `Skill` partition and rebased
afterwards. Merge this first.
Public surface is ADDED only (`WithPath`, `WithNamespace`) — nothing renamed or
removed, so no in-mesh caller can break.
Closes #2939
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a subtle but high-impact integrity bug where rebasing a MeshNode via a record copy updates the computed Path but can leave the stored MainNode pointing at the old location, making otherwise-healthy nodes vanish from is:main-based search/listing while still being readable via get.
Changes:
- Added
MeshNode.WithPath(id, ns)/WithNamespace(ns)to rebasePathand (when not explicitly authored)MainNodetogether. - Centralized and broadened the “stale self-default MainNode” repair so it applies consistently across create, batch create, and upsert/update flows (including no-op upsert detection).
- Added targeted regression tests plus documentation/What’s New entries to capture the failure mode and the safe rebase rule.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.PluginCatalog.Test/NodeRepoMainNodeRebaseTest.cs | Regression test proving install rebases keep nodes as their own main node unless MainNode was explicitly authored. |
| test/MeshWeaver.Hosting.Monolith.Test/PartitionRootBootstrapTest.cs | Adds create-path tests for the deeper “namespaced stale self-default from another partition” shape plus negative controls. |
| test/MeshWeaver.Hosting.Monolith.Test/CreateOrUpdateNodeRequestTest.cs | Adds update/upsert regression test ensuring re-import heals already-corrupted rows that a source node can’t express as “self-main”. |
| test/MeshWeaver.Data.Test/MeshNodeRebaseTest.cs | Unit tests codifying the record-copy trap and verifying WithPath/WithNamespace behavior (including explicit MainNode). |
| src/MeshWeaver.PluginCatalog/PackageInstaller.cs | Switches canonical node rebasing to WithPath to avoid freezing stale MainNode during install. |
| src/MeshWeaver.Mesh.Operations/MeshOperations.cs | Replaces with { Namespace/Id = … } rebases with WithNamespace/WithPath to keep MainNode aligned when it wasn’t explicitly set. |
| src/MeshWeaver.Mesh.Contract/MeshNode.cs | Introduces the WithPath/WithNamespace APIs with detailed guidance on why plain record copies are unsafe here. |
| src/MeshWeaver.Mesh.Contract/MeshExtensions.cs | Consolidates the repair rule into RepairStaleSelfDefaultMainNode, applies it to create + batch create + upsert, and ensures no-op detection doesn’t skip required repairs. |
| src/MeshWeaver.Hosting/Persistence/EmbeddedResourceStorageAdapter.cs | Uses WithPath during served-path normalization to prevent stale MainNode when aligning Id/Namespace. |
| src/MeshWeaver.Documentation/DocumentationNodeProvider.cs | Uses WithPath when aligning doc-node Id/Namespace to the served path so MainNode stays consistent. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-node-moved-into-a-partition-stays-findable.md | User-facing release note describing the bug and the fix. |
| src/MeshWeaver.Documentation/Data/Architecture/MainNodeRebasing.md | Architecture write-up documenting the trap, the rule, and the repair guard’s intentionally narrow trigger. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Adds the new architecture page to the index. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tMainNode, never Path Copilot review, #2949. The doc comment claimed allocation-free while the body called `node.Path`, a computed interpolation that allocates a string per call — on a predicate that runs on every create and every upsert in the mesh. Both uses are gone. "Is MainNode this node's own path" is exactly MeshNode.HasExplicitMainNode, which already compares segment-wise for this reason, and the partition comparison runs on Namespace directly — its first segment IS the node's partition, so Path never has to be built. An empty namespace now short-circuits arm (b) explicitly instead of relying on the caller's guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
# Conflicts: # src/MeshWeaver.Documentation/Data/Architecture.md
Two corrections from the session that measured the defect. 1. The scope is SEVEN skills, not six: a reachability sweep of all 53 packages (reading the mesh by two independent paths, `get` vs `search`, and reporting the disagreement) adds `RemoteControl/Skill/remote`. The unreachable set is EXACTLY the set where `main_node != path`, zero false positives. 2. 🚨 Restoring `MainNode == Path` is NECESSARY BUT NOT SUFFICIENT. A second, independent defect produces the identical symptom — #2942: MeshQueryRequest .FromQueries builds a union but fills the legacy single `Query` field with list[0]; StaticNodeQueryProvider reads `Query` while StorageAdapterMeshQueryProvider iterates `EffectiveQueries`, so a static node matched only by query #2 is silently absent — and the skill query set leads with the platform catalog and follows with the package partition, i.e. exactly query #2. Fixing either half alone leaves the decentral skills unreachable. Prose that claimed this fix restores search visibility is corrected everywhere it appeared: the What's New entry (renamed off "stays findable"), the doc page (which gains a "One symptom, two independent defects" section), MeshNode/MeshExtensions/ PackageInstaller remarks, and the three test summaries. Assertions are unchanged — they were always about the stored value, which is what this change owns. Also struck: the implication that an import had DROPPED nodes. It had not. The nodes are Active at version 7; the reported "absence" was this defect. And lastSyncCommitSha is not per-partition import evidence — 33 of 34 partitions record the identical sha while lastSyncedAt spans 25 days. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
rbuergi
added this pull request to the merge queue
Sep 1, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 1, 2026
rbuergi
added this pull request to the merge queue
Sep 1, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 1, 2026
rbuergi
added this pull request to the merge queue
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2939. One of two required halves — see "Not sufficient on its own" below. Pairs with Systemorph/MeshWeaver.Plugins#1053 (the origin), which merges after this.
The defect
MeshNode.MainNodeis a stored, non-nullable init property whose default is evaluated once, at construction.Pathis computed. So a record rebase moves one and not the other:Because the field can never be null, "the writer never touched it" and "the writer meant this" are the same bytes —
HasExplicitMainNodereads true for the stale value, so every writer downstream persists it as deliberate. The node drops out ofis:main(SQLn.main_node = n.path) whilegetreturns it,Activeand fully formed. Nothing errors, nothing logs, no status flips.Seven live nodes on
memex.meshweaver.cloud:Hosting/Skill/{deployment,deployment-activity,instance,platform-update},Essentials/Skill/email,RemoteControl/Skill/remote,Store/Skill/ci-policy. A reachability sweep across all 53 packages — reading the mesh by two independent paths (getvssearch) and reporting the disagreement — found the unreachable set to be exactly the set wheremain_node != path, with no false positives.Reproduced live, not argued
Measured on the memex portal (full evidence is commented on #2939):
Same partition, same node type, same import path; the only difference is the pointer. All seven are
get-able and absent from the listing.This also measures the 1b′ guard's gap rather than asserting it. The stale values are two-segment defaults (
Skill/deployment), not the one-segmentMainNode == Idthe old guard triggered on — so the guard looked at these nodes and correctly found nothing matching its rule. That is precisely the shape the second arm added here catches.🚨 Not sufficient on its own
Restoring
MainNode == Pathdoes not by itself make a decentral node searchable. A second, independent defect produces the identical symptom — #2942:MeshQueryRequest.FromQueriesbuilds a union but fills the legacy singleQueryfield withlist[0];StaticNodeQueryProviderreadsQuerywhileStorageAdapterMeshQueryProvideriteratesEffectiveQueries. A static node matched only by query #2 is silently absent — and the skill query set leads with the platform catalog and follows with the package partition, i.e. exactly query #2.Both halves are required; fixing either alone leaves the decentral skills unreachable. This PR is the stored-value half, and claims nothing about search on its own.
What changed
1. One rebase API —
MeshNode.WithPath(id, ns)/WithNamespace(ns). MovesPathandMainNodetogether; preserves an AUTHOREDMainNode(a package's_Accessgrant'smainNodeIS its scope).with { Namespace = … }is now the shape to grep for.PackageInstaller.ParseCanonicaluses it. ItsMainNode = parsed.MainNode ?? (…)was dead code — the field is non-nullable, so the right-hand side never ran, and the stated intent (preserve an authored mainNode) cannot be expressed with a null check. 🚨 The install bulk path writes throughpersistence.WriteManyAndPublishCreated, bypassingHandleCreateNodeRequestentirely (measured — see the negative control below), so this line is the only defence on an install.2. One repair guard, now covering updates. The 1b′ repair was pasted into the singular and the batch create path. It is now one helper,
RepairStaleSelfDefaultMainNode, used by both — plusCreateOrUpdateNodeRequest, on the merged node. That last one matters: a full-instance upsert can move aMainNodeanywhere except back onto the node's own path (that intent is exactly what reads as untouched —MeshNode.MainNode's own remarks), so a re-import compared equal, skipped as a no-op, and left the pointer wrong forever.IsNoOpUpsertnow knows the stored row may itself need repair.The trigger stays deliberately narrow — both halves are load-bearing:
MainNode == Idon a namespaced node/{Id}and a different first segmentGitHubSyncConfig'sMainNode = spacePath; a~/Threadstile targeting{owner}/ThreadsStore/Foowith idStore-FooAn over-reaching repair is the reverse defect (#2383): it promotes a satellite to a main node and re-scopes its grants.
3. The rest of the class. Sweep of
with { Namespace = … }on aMeshNodeacross both repos: 4 sites carried the defect and now useWithPath—EmbeddedResourceStorageAdapter,DocumentationNodeProvider,MeshOperations.NormalizeNamespace(an agent's@/ACME/Projectsnamespace was normalised, its MainNode was not) andSanitizeNodeId.FileSystemStorageAdapter,CachingStorageAdapter,PathRemappingStorageAdapterand the move rebase already handled MainNode explicitly and are untouched. Plugins had exactly one, fixed in the paired PR.Two sites deliberately not changed:
PackageInstaller.ParseNode:3345andGitHubSyncService.cs:707assignMainNode = "{rebasedNs}/{id}"unconditionally. They heal a stale default but clobber an authored one — the opposite defect. Rebasing an authored pointer into a partition means prefixing it, which is a judgement call that does not belong in this change set.4. The finding is conserved —
Doc/Architecture/MainNodeRebasing, linked from the Architecture index, including the two-defects-one-symptom section.Public surface
Added only (
WithPath,WithNamespace) — nothing renamed or removed, so no in-mesh caller the compiler cannot see can break.Verified
Every touched project + dependents built
-c Release -warnaserror, one project per invocation: 0 Warning(s), 0 Error(s).Tests, all green:
MeshNodeRebaseTest7, PluginCatalog install suites 16,PartitionRootBootstrapTest+CreateOrUpdateNodeRequestTest+NodeVersionCountsRealChangesTest28,Hosting.Test269,NodeOperations.Test76,Documentation.Test148.Negative controls — every new test was proven able to fail. With the
??restored and the guard's second arm disabled:NodeRepoMainNodeRebaseTest→Expected "Gadget/Skill/deployment" … but found "Skill/deployment"PartitionRootBootstrapTest.MainNode_StaleNamespacedSelfDefault…→ same shapeCreateOrUpdateNodeRequestTest.Upsert_HealsAStoredStale…→ same shapeThen, with the guard restored but
ParseCanonicalstill inverted, the install test still failed — which is how we know the installer bypasses the create guard and thatParseCanonicalis independently pinned. Two negative-control tests (a deliberate cross-partition pointer; a self-default-shaped pointer inside its own partition) passed throughout. The doc page also failedDocumentationLinkIntegrityTestonce on bare sibling links before they were made absolute.Re-verified after merging
origin/main(six commits, one conflict inArchitecture.mdwhere both sides added a table link — resolved by keeping both;git diff origin/mainconfirms nothing of main's was dropped).Not in this PR — the live data repair is OUTSTANDING
This change does not repair the seven existing rows. It stops new ones being created, and it lets an upsert heal a stale pointer if and when one touches that node — but nothing here reaches out and fixes stored data. The repair is a separate, explicit action on a live portal:
for
Hosting/Skill/{deployment,deployment-activity,instance,platform-update},Essentials/Skill/email,RemoteControl/Skill/remote,Store/Skill/ci-policy.Acceptance for that repair is the same two-read check that found the defect:
search nodeType:Skillgoes from 71 → 78. Anyone can verify it; it is not a claim only the repairer can check.Sequencing is deliberate — the repair runs after this merges. Repairing first would let the next install re-corrupt the rows, and the work would have to be done twice.
And a reminder from above: even a repaired pointer does not restore search visibility on its own. #2942 is the other half.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm