Repository navigation
Compile state is mesh-owned — no upsert writer gets to overwrite it (#748 stage 2) - #950
Merged
Merged
Conversation
A NodeType node carries two things with different owners: the AUTHORED definition, which a repo / package / user owns, and the compile bookkeeping, which only the mesh that ran the compile can know. Every upsert writer holds a copy of the second one that is stale by construction — a repo file embeds the verdict it was exported with, an installer ships the package author's, and a sync's `existing` snapshot comes from the eventually-consistent query index, which lags the compile pipeline because the compile does not run under the writer's lock. Whichever copy the writer held, it won. That is how four healthy SocialMedia types on memex reverted to their pre-framework-roll `Pending` with a `latestReleasePath` naming a release that had already rotated away (2026-08-02): the compile succeeded, the satellite recorded it, and the next re-import stamped the index's older view back over the node. The mirror image is worse — a weeks-old "Ok" claims an assembly nobody still has, and the type looks healthy right up to the cold cache that parks it. StaticRepoImporter tried to hold this line client-side, carrying the operational members over from that same lagged snapshot. It could only ever be as fresh as the index it read, and reading one node's content from the query is the shape CQRS exists to forbid. Move the rule to the owner, inside the upsert's own merge, where the answer is authoritative and every writer is covered instead of the one that remembered: GitSync import, plugin install, webhook, instance sync, MCP. Cross-hub the effect is stronger than "read something fresher" — the members come out equal to what the handle already holds, so they drop out of the RFC 7396 patch entirely and a stale mirror cannot even express the regression. IsNoOpUpsert mirrors it, which is the cheaper half of the same harm: a re-import whose only difference was bookkeeping used to take the write path and mint a Version for nothing, and that churn is exactly what marks a NodeType "server-modified" so the two-way sync starts preserving a local edit nobody made. Deliberate compile writes are untouched — the watchers, RequestNodeTypeRelease and the MCP compile tool all go through GetMeshNodeStream(path).Update, never an upsert. Also records, on NodeTypeCompileState, the constraint the remaining phases of #748 have to design around: CompilationStatus IS the compile lock, and its compare-and-swap is atomic only because the NodeType hub owns the node it swaps on. The satellite is a different path, so a different owner, so a remote handle — a CAS evaluated there reads a snapshot that is not the owner's state at apply time, and two contenders can both conclude they transitioned and both start Roslyn. The control plane moves with its watchers or it does not move; splitting it across two owners is the one design that must not ship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements stage 2a of #748 by enforcing “compile state is mesh-owned” at the owner-side upsert merge (CreateOrUpdateNodeRequest), so no upsert writer (GitSync import, plugin install, instance sync, MCP, etc.) can overwrite live compile bookkeeping with a stale copy.
Changes:
- Apply NodeType operational/compile-state preservation inside the upsert merge (and mirror it in the no-op upsert detector) so stale operational fields cannot land and unchanged re-imports become true no-ops.
- Remove the importer’s client-side preservation (which could only be as fresh as the query index snapshot) and rely on the authoritative owner merge instead.
- Add an end-to-end regression test covering “authored change lands + stale compile verdict does not regress live state,” plus a What’s New entry describing the user-visible effect.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Monolith.Test/CreateOrUpdateNodeRequestTest.cs | Adds regression test proving an upsert can carry authored edits without regressing live compile state. |
| src/MeshWeaver.Mesh.Contract/NodeTypeOperationalContent.cs | Updates documentation to reflect the new owner-side “every upsert preserves live operational state” rule. |
| src/MeshWeaver.Mesh.Contract/MeshExtensions.cs | Enforces operational-state preservation in the upsert merge and applies the same rule in IsNoOpUpsert comparisons. |
| src/MeshWeaver.Graph/StaticRepoImporter.cs | Removes importer-side preservation and documents why it must not rely on eventually-consistent query snapshots. |
| src/MeshWeaver.Graph/Configuration/NodeTypeCompileState.cs | Records the compile-lock ownership constraint for later phases of moving compile state to a satellite. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-nodetype-compile-state-survives-sync.md | Adds a What’s New entry describing that sync/install/import no longer reverts NodeType compile state. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
…eType PreserveMeshOwnedOperational's XML doc says it returns the source node untouched when there is nothing mesh-owned to restore, but the reshape ran unconditionally: a plain content upsert with no NodeType allocated a fresh MeshNode record with the same field values. Guard on state.NodeType so the no-op case returns the same instance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Fixes #748.
Partial: this is a stage, not the whole issue. It closes the harm #748 opens with ("imports
stamped it back") at its root and records the constraint the remaining phases have to design
around. The field move itself is not done — see What is left below, with the reason.
The defect
A NodeType node carries two things with different owners: the AUTHORED definition (a repo, a
package, a user owns it) and the compile bookkeeping (only the mesh that ran the compile can know
it). Every upsert writer holds a copy of the second one that is stale by construction:
StaticRepoImporter'sexistingsnapshot comes frompath:{p} scope:descendants— theeventually-consistent query index, which lags the compile pipeline because the compile does not
run under the importer's lock.
Whichever copy the writer held, it won. That is the mechanism behind the symptom recorded on this
issue (memex, 2026-08-02): four healthy
SocialMediatypes reverted to their pre-framework-rollPendingwith alatestReleasePathnaming a release that had already rotated away. The compilehad succeeded; the satellite recorded it; the next re-import stamped the index's older view back
over the node. The mirror image is the documented stale-green class — a weeks-old
Okclaiming anassembly nobody still has, which looks healthy right up to the cold cache that parks the type.
StaticRepoImportertried to hold this line client-side (PreserveLiveOperationalagainsttarget). It could only ever be as fresh as the index it read, and reading a single node's contentfrom the query is precisely the shape CQRS exists to forbid.
The fix
Move the ownership rule to the owner, inside
CreateOrUpdateNodeRequest's merge(
MeshExtensions.PreserveMeshOwnedOperational), and delete the importer's client-side copy.sync, MCP — instead of the one that remembered to patch it up.
handle already holds, so they drop out of the RFC 7396 patch entirely and the owner keeps its
own. A stale mirror cannot even express the regression.
RequestNodeTypeRelease, the MCP compile tool — goes throughGetMeshNodeStream(path).Update,never an upsert. Verified against the full writer inventory.
IsNoOpUpsertmirrors the merge, which is the cheaper half of the same harm: a re-import differingonly in bookkeeping used to take the write path and mint a Version for nothing — and that churn is
exactly what marks a NodeType "server-modified", so the two-way sync starts preserving a local edit
nobody made.
Absent-in-live stays absent: a verdict baked into a repo file can never seed a node that has none.
The constraint the rest of #748 must design around (recorded in
NodeTypeCompileState)CompilationStatusis the compile lock.HandleDispatchCompiletransitions Pending → Compilinginside the per-NodeType hub's OWN
Updateand dispatches Roslyn only when that lambda made thetransition — atomic solely because the hub owns the node it swaps on, so its action block serialises
every contender.
The satellite is a different path, therefore a different owner:
MeshNodeStreamHandle.IsOwnisplain path equality with the hub address, so from the NodeType hub the satellite is a remote
handle. A remote
Updateevaluates its lambda against the local mirror and ships the diff, so theguard would be read off a snapshot that is not the owner's state at apply time — two contenders can
both conclude "I transitioned" and both start Roslyn, and the merge refusing one write afterwards
does not un-dispatch the compile that already began. The trigger pair
(
RequestedReleaseAt/LastReleaseRequestHandledAt) has the same shape.So the control plane moves exactly one of two ways, both single-owner: (a) the compile watchers
move with it onto the satellite's own hub, which then owns the CAS; or (b) the control-plane
members stay on the node and only the RESULT members move. Splitting the CAS across two owners is
the one design that must not ship — the double-compile it admits is the "hub unresponsive after the
second compile" wedge class.
What is left
Phase 1 (satellite + mirror) landed in #752. This PR is stage 2a. Still open:
writing the node members, re-point the
DataExtensionsmonotonic-trigger merge guard, coordinatedMeshWeaver.PluginsPR forNodeTypeCompileGate/Provisioning. A full inventory taken for thisPR puts it at ~500 sites in
src/+memex/and ~500 intest/, across the most wedge-pronesubsystem in the repo — not one PR, and the lock question above has to be answered first.
ReleaseNotesis mis-classified. It sits inMemberNames(mesh-owned) yet is user-authoredthrough a
JsonPointerReferencebinding inNodeTypeLayoutAreas/NodeTypeConfigForm. With thischange an upsert can no longer carry it, which is right for a sync and wrong for the form —
the form writes via
stream.Update, so nothing regresses today, but the classification should besettled before the field moves.
PackageInstaller.IsUnchangedstill carries the narrow NodeType special-case the issue callsremovable. Replacing it with a strip-both-sides compare has to be sequenced after
AlignedIncoming(typed content materializes C# defaults) or it re-opens the root-churn flap — deliberately not
touched here.
samples/Graph/Data/**/*.jsonNodeType files still embed compile verdicts (compilationStatus,latestAssemblyPath,compiledSources, …). Export strips them now, so committing the strippedform is safe — but it shifts first-compile timing for ~20 dynamic-NodeType tests, so it wants its
own PR.
verdict. Stripping there would break established fixtures that seed compile state on create, so it
needs its own decision.
Legacy persisted data
No migration, and none needed — nothing is removed from the shape.
NodeTypeDefinitionkeepsall 19 members, so every stored node deserializes exactly as before. The change is only about who
gets to write them: an existing node's values are now what survives an upsert instead of what the
writer brought. A node that never had them still has none.
(When phase 3 does remove the properties,
[JsonExtensionData] UnknownMembersonNodeTypeDefinitionmakes that deserialization-safe for stored nodes — confirmed present. This PR does not rely on it.)
Verification
Full solution, CI flags:
dotnet build MeshWeaver.slnx -c Release -p:CIRun=true -warnaserror— clean,0 warnings.
MeshWeaver.GitSync.Test(full)NodeTypeOwnershipSyncTest, the end-to-end proof that the import still keeps live compile state with the importer's own preserve deleted, and that the exported file carries no verdictMeshWeaver.PluginCatalog.Test(full)IsNoOpUpsertMeshWeaver.Graph.Test(operational / compile-state / importer / conflict-policy)CreateOrUpdateNodeRequestTest,ReleaseRequestWatcherHighWaterTest,NodeTypeCompileParkTest,CompileSourceSnapshotWedgeTestCodeEditRecompileTestMeshWeaver.Documentation.TestNew test:
CreateOrUpdateNodeRequestTest.Upsert_WithStaleCompileState_TakesAuthoredChange_AndKeepsLiveCompileState— an upsert carrying an authored change plus an older verdict lands the change and keeps the live
version, assembly pointer and status; an operational member absent on the live node stays absent.
The "exported form carries no compile state" assertion already exists and still passes
(
NodeTypeOwnershipSyncTest,NodeTypeOperationalContentTestpins the member set against the recordin both directions) — not duplicated.
🤖 Generated with Claude Code