NodeType Release Redesign
This document is the original design proposal for first-class Release MeshNodes, which superseded the implicit edit-then-invalidate-cache compile flow with an explicit, observable, version-pinned release pipeline.
π¨ This design SHIPPED, under different names and a different trigger. Read the sections below as the historical rationale, not as an API reference β and never copy their code. What actually exists today:
In this proposal What shipped Release : ActivityLogNodeTypeRelease(MeshWeaver.Graph.Configuration) β a plain record, not anActivityLog. It mirrors the compile's terminalStatusand links the run viaCompilationActivityPath.CreateReleaseRequest/CreateReleaseResponseThe trigger is a stream.Updatecontrol-plane field: setNodeTypeDefinition.RequestedReleaseAt(+RequestedReleaseForce) viaworkspace.GetMeshNodeStream(nodeTypePath).Update(...), or callhub.RequestNodeTypeRelease(...). ACreateNodeTypeReleaseRequest/Responsepair still exists as legacy plumbing β never post one from new code (see Request via stream.Update).NodeTypeService.GetCachedConfiguration/GetActiveReleaseStreamNodeTypeServiceno longer exists. The active release is theNodeTypeDefinition.LatestReleasePathfield on the NodeType node β read it directly, do not resolve the active release with aQuery(that round-trip is exactly what the field replaced).RequestedReleasePathpins a specific historical release.InvalidateCacheonNodeTypeServiceICompilationCacheService.InvalidateCache(nodeName)β still present, on the cache service.AssemblyPathas the durable artefactAssemblyPathis a process-local hint; the cross-silo durable reference isAssemblyCollection+AssemblyContentPath(content-collection blob) plusAssemblyStoreVersion.The C# blocks below violate current platform rules (
Observable.FromAsync, a blocking.Wait(), an unsubscribedUpdateMeshNode). They are annotated in place and kept only to show what was proposed. See Asynchronous Calls and Controlled I/O Pooling.
Why the old flow breaks down
The current NodeType compile flow is implicit and entirely process-local:
- A user edits a
NodeTypeor one of itsSource/children. - The change-feed fires
NodeTypeService.InvalidateCache(nodeTypePath), which clears in-memory dictionaries. - The next access triggers a Roslyn compile and loads the result into a
process-local
AssemblyLoadContext(ALC).
That sounds simple, but there are four failure modes that compound one another.
The ALC key mismatch. Release-based ALCs are keyed in _loadContexts by
release.Path. InvalidateCache looks up by nodeName, finds nothing, skips
the GC sweep, and File.Delete on the cached .dll throws
UnauthorizedAccessException. This is what kept
CodeEditRecompileTest (test/MeshWeaver.Hosting.Monolith.Test) skipped at the time.
(It runs today β the test is no longer skipped.)
No observable feedback. Users have no signal while a compile is running,
when it finishes, or whether it succeeded. Diagnostics are read on demand via
GetCompilationError(nodeTypePath) β a polled, in-memory dictionary.
No rollback. Old assemblies are deleted before the new compile starts. If the new compile fails, there is nothing to fall back to.
No history. Once a compile succeeds, the previous version is gone. There is no audit trail of what changed and when.
The redesign goal: treat releases as first-class, observable, versioned MeshNodes so the framework's existing Activity Control Plane machinery handles progress, cancellation, diagnostics, and rollback automatically.
The model
Each Release is a MeshNode of type Release at
{nodeTypePath}/Release/{version}. Versions are user-supplied or auto-stamped
(timestamp + short hash). A Release owns its own .dll on disk at a path that
is stable for the (nodeTypePath, version) pair. Releases accumulate; old ones
remain as history.
Schema
π¨ Proposed shape, not the shipped one. The real type is
NodeTypeRelease(MeshWeaver.Graph.Configuration) β a plain record that does not derive fromActivityLog. It carriesStatusas a mirrored string plusCompilationActivityPath(the link to the live message log), and addsAssemblyCollection/AssemblyContentPath/AssemblyStoreVersionfor cross-silo activation andSourceVersions/TestVersionssnapshots. Code against that type.
public sealed record Release : ActivityLog("NodeTypeRelease")
{
/// <summary>
/// The NodeType this release was built from. Stable across the release's
/// lifetime; a release belongs to exactly one NodeType.
/// </summary>
public required string NodeTypePath { get; init; }
/// <summary>
/// User-supplied version label (e.g. "1.2.0", "feature-x"). When null,
/// auto-stamped by the create handler with a timestamp + 8-char hash of
/// the compilation inputs.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Release notes β markdown body the author writes to describe the
/// release. Surfaces in the UI release history list and at the top of
/// the Release detail view.
/// </summary>
public MarkdownContent? Notes { get; init; }
/// <summary>
/// Snapshot of the compilation inputs at release time. Stored on the
/// release so a future replay can verify the inputs match. Same hash
/// used to derive the disk path.
/// </summary>
public required string Code { get; init; }
public string? HubConfiguration { get; init; }
public IReadOnlyList<ContentCollectionConfig>? ContentCollections { get; init; }
public required string FrameworkVersion { get; init; }
public required string ContentHash { get; init; } // 16-char base64
/// <summary>
/// Filesystem path of the compiled DLL. Set when the compile activity
/// terminates with <c>Succeeded</c>; null on failure. Path is
/// <c>{cacheDir}/{nodeTypePath-sanitized}/{version}/Release.dll</c>.
/// </summary>
public string? AssemblyPath { get; init; }
public string? PdbPath { get; init; }
// Inherited from ActivityLog:
// Status β Pending β Compiling β Succeeded / Failed
// RequestedStatus β control plane (e.g. set to Cancelled to abort)
// Messages β Roslyn diagnostics during compile
// Start, End β when compile started / finished
// ReturnValue β JsonElement of the AssemblyPath (also set above)
}
Release derives from ActivityLog so the existing
Activity Control Plane machinery β observable progress
via workspace.GetMeshNodeStream(releasePath), cancellation via
RequestedStatus = Cancelled, and real-time message streaming β comes for free.
Lifecycle
1. Create-release request
π¨ Superseded β do not write a request type for this. The shipped trigger is a
stream.Updatecontrol-plane field: setNodeTypeDefinition.RequestedReleaseAt(withRequestedReleaseForcefor "bypass the sources-unchanged short-circuit") throughworkspace.GetMeshNodeStream(nodeTypePath).Update(...), or callhub.RequestNodeTypeRelease(...). The per-NodeType hub's watcher reacts and dispatches the compile, idempotently, off the last-handled stamp.
// β HISTORICAL shape. The legacy `CreateNodeTypeReleaseRequest`/`Response` pair still
// exists as plumbing β never post one from new code.
public sealed record CreateReleaseRequest(string NodeTypePath, string? Version, MarkdownContent? Notes)
: IRequest<CreateReleaseResponse>;
public sealed record CreateReleaseResponse(string ReleasePath, string? Error = null)
{
public bool Success => string.IsNullOrEmpty(Error);
}
The handler at the mesh hub:
- Reads the current
NodeTypeDefinitionandSource/content ofNodeTypePath. - Computes
ContentHashover those inputs. - Creates a
ReleaseMeshNode at{NodeTypePath}/Release/{Version ?? autostamp()}withStatus = Compiling. - Posts back
CreateReleaseResponsewith the release path immediately (just-start, matching theScriptDispatch.StartScriptpattern). - The Release's hub fires the compile asynchronously.
2. Compile activity (per release)
Each Release MeshNode's hub watches its own MeshNodeReference stream via
hub.WatchControlPlane(...). When RequestedStatus = Compiling (set on
create), it fires the Roslyn compile in the background:
π¨ Do not copy this block.
Observable.FromAsyncis forbidden outsideIoPool(it runs the prologue on the subscribing thread β i.e. the hub's action block β with no concurrency bound); the Roslyn compile is a blocking leaf and belongs onpool.InvokeBlocking(...). AndUpdateMeshNodehere is never subscribed, so the write would silently not happen β the shipped code composesGetMeshNodeStream(path).Update(...).Subscribe(_ => { }, ex => β¦).
hub.RegisterForDisposal(hub.WatchControlPlane(requested =>
{
if (requested != ActivityStatus.Compiling) return;
Observable.FromAsync(ct => CompileReleaseAsync(hub, ct)) // β FORBIDDEN β use IIoPool
.Subscribe(
assemblyPath =>
hub.GetWorkspace().UpdateMeshNode(curr =>
curr.Content is Release r
? curr with { Content = r with {
Status = ActivityStatus.Succeeded,
AssemblyPath = assemblyPath } }
: curr),
ex =>
hub.GetWorkspace().UpdateMeshNode(curr =>
curr.Content is Release r
? curr with { Content = r with {
Status = ActivityStatus.Failed,
Messages = r.Messages.Add(new LogMessage(ex.Message, LogLevel.Error)) } }
: curr));
}));
Roslyn diagnostics flow into Release.Messages (inherited from ActivityLog)
during the compile, using the same per-Activity logger pattern as the kernel.
3. Resolution: which release is active?
π¨ Superseded β and the proposal below is the shape that was rejected. Resolving the active release with a
Queryis eventually consistent (stale right after a compile) and costs a round-trip, and the.Wait()is a blocking sync-over-async read that deadlocks on a hub. What shipped instead: the answer is a field on the NodeType node βNodeTypeDefinition.LatestReleasePath, written by the compile watcher after a successful compile and preserved across failed compiles, so consumers keep loading the last-known-good release. Read it offGetMeshNodeStream(nodeTypePath); never query for it.RequestedReleasePath, when set, pins activation to a specific historical release instead (production pinning / rollback).
The proposal was for NodeTypeService.GetCachedConfiguration(nodeTypePath) to become a
stream-backed read keyed off a release feed:
// β HISTORICAL β do not copy. `.Wait()` blocks the caller; the Query below is
// eventually consistent. Read NodeTypeDefinition.LatestReleasePath instead.
public NodeTypeConfiguration? GetCachedConfiguration(string nodeTypePath) =>
GetActiveReleaseStream(nodeTypePath)
.Take(1)
.Select(release => release?.AssemblyPath is { } path ? LoadConfig(path) : null)
.Wait(); // sync read for the cached path; observable variant for hot paths
private IObservable<Release?> GetActiveReleaseStream(string nodeTypePath) =>
meshService.Query<MeshNode>(
MeshQueryRequest.FromQuery($"namespace:{nodeTypePath}/Release nodeType:Release"))
.Select(change => change.Items
.Select(n => n.Content as Release)
.Where(r => r is { Status: ActivityStatus.Succeeded, AssemblyPath: not null })
.OrderByDescending(r => r!.Start)
.FirstOrDefault());
The active release is always the latest Succeeded one. That property did survive: failed compiles never become active, and users keep running on the previous release until they ship a fix in a new one.
4. ALC management
CompilationCacheService becomes Release-keyed:
GetOrCreateLoadContextForRelease(release)keys_loadContextsbyrelease.Path(already does this, but loads from a version-stable folder rather than a hash-stable one).- DLL path:
{cacheDir}/{nodeTypePath-sanitized}/{version}/Release.dll. The path is stable for the same(NodeTypePath, Version)pair. Re-running a compile against an existing version overwrites in place but never deletes a different version's DLL. - Switching active release: when a new Release becomes the latest Succeeded,
the previous release's ALC stays in
_loadContextsuntil explicitly unloaded.NodeTypeServicecallscacheService.UnloadContext(prevRelease.Path)when the active release advances. The DLL on disk is kept β only the ALC is disposed. New per-node hub activations bind to the new release's ALC; existing per-node hubs stay on the previous ALC until they are recycled. InvalidateCache(nodeTypePath)is deleted. Releases are immutable and durable β there is nothing to invalidate. The replacement is "create a new release," which the user does explicitly.
5. UI surfaces
| View | Path | Content |
|---|---|---|
| Release history | {nodeTypePath}/Release/* |
List of Releases β Status, Version, CreatedAt, Notes preview |
| Release detail | {nodeTypePath}/Release/{version} |
Full Notes (rendered markdown), full Activity log, DLL/PDB download links |
| Create release form | NodeType detail page | Version field (optional), Notes textarea (markdown), Submit button |
On submit, the form posts CreateReleaseRequest, navigates to the new Release's
detail view, and the user watches the compile happen in real time via the
Activity Control Plane subscription.
Migration plan
The redesign is invasive but strictly additive: Release MeshNodes are introduced alongside the existing cache, readers are flipped one consumer at a time, and the old implicit path is deleted last.
| Phase | What | Risk |
|---|---|---|
| 0 | Add Release content type + CreateReleaseRequest/Response + handler. |
Low β new code, no existing consumers. |
| 1 | Add UnloadContext(release.Path) callsite in CompilationCacheService when the active release advances (no new behaviour, just gives NodeTypeService the hook it needs). |
Low |
| 2 | Wire compile-Activity to the Release node (extend NodeTypeCompilationActivity to emit on a Release content node, not a generic Activity node). |
Medium β Activity Control Plane changes. |
| 3 | Add INodeTypeService.GetActiveReleaseStream reactive read; default GetCachedConfiguration to consult releases when present, falling back to the in-memory cache when not. |
Medium β read-path change, but additive (fallback preserves current behaviour). |
| 4 | UI: Release history + detail + create-release form. | Medium β UI work. |
| 5 | Back-compat shim: existing NodeTypes without Releases auto-release on first compile, writing a Release MeshNode with the auto-stamped version. | Medium |
| 6 | Delete InvalidateCache, _compilationErrors, _compilingInProgress from NodeTypeService. The whole implicit-invalidation path goes away. |
High β fan-out across many call sites. |
CodeEditRecompileTest was to un-skip at phase 3, rewritten as:
create V1 release β read V1 β create V2 release β read V2 marker β exercising the
explicit-release path with no InvalidateCache call and no file-delete race. It is
un-skipped and running today.
Open questions for review
Version naming default. When the user omits Version, the suggested auto-stamp format is
{yyyyMMddHHmmss}-{8charContentHash}β sortable and unique.Garbage collection. Releases accumulate indefinitely. A TTL, "keep last N," or explicit-delete policy is probably needed, but deferred as a follow-up.
Cross-instance compilation. Releases are MeshNodes, so they replicate across instances. Compiled DLLs on disk are per-instance. A Release that succeeded on instance A still needs to compile on instance B. This should be idempotent: same inputs β same content hash β same release ID β same target path β already-compiled is a no-op.
Failed releases β keep or drop? Proposal: keep (Status=Failed) and surface them in history. The Notes and Activity messages explain why the compile failed, which is useful for triage.
Concurrent create-release. Two users creating a release for the same NodeType simultaneously will get different auto-stamped versions (timestamp differs). Both compile independently; the latest Succeeded wins active status. This is probably fine.
2026-09-02 β the post-condition at the settle (#781)
Symptom. Publish/Deck had compiled the current source and instances kept binding the previous
day's assembly. The node's own state said it, and said it quietly:
lastCompileSucceededAt 2026-08-27T21:53:01.850Z
lastCompiledVersion 575
latestAssemblyPath Publish_Deck/v575-s8929555-aadb349047af.dll
requestedReleaseAt 2026-08-27T21:52:59.898Z
lastReleaseRequestHandledAt 2026-08-27T21:52:59.898Z β consumed
latestReleasePath Publish/Deck/Release/20260826065548-neI3XM25 β the PREVIOUS DAY
compilationStatus: Ok, compiledSources identical to currentSourceVersions, an assembly built,
a release path present. Healthy from every angle. Only comparing lastCompiledVersion against the
release reveals it, and nothing was comparing them.
What was NOT the cause
The obvious reading β "a release request fired while a compile was in flight" β is wrong, and
chasing it would have produced a second gate that already exists. InstallReleaseRequestWatcher
has gated on a SETTLED status (not Pending and not Compiling) since long before the incident:
&& def.CompilationStatus is not CompilationStatus.Pending
and not CompilationStatus.Compiling
Checked out at 2026-08-01, 2026-08-20 and 2026-08-27T21:00 β present in all three, all before the 21:52:59 request. So at the moment the request was handled, nothing was in flight.
What it actually is: an ordering the request side cannot see
Two ways to reach the same state, and the fix must not care which one happened:
- The request cut the build that was current at 21:52:59 β correctly, by its own contract β and the compile that finished at 21:53:01 started after it. Nothing revisits the result.
- The release create simply did not land.
TryCreateReleaseNodeis best-effort by design (compile correctness must not depend on a MeshNode create), so an expired bound, a fault, or a refusal all emitnull, logged at Warning and swallowed. The create runs under the REQUESTER's identity for attribution, so a partition the requester may not create in refuses it β andAccess denied: user 'rbuergi' lacks Update permission on 'Publish/Deck'makes that the leading candidate for that night.
In both, ApplyCompileSuccess stamps releasePath ?? def.LatestReleasePath β the previous build's
release β while LastReleaseRequestHandledAt was stamped on the DISPATCH commit and the trigger is
therefore already spent. Nothing retries, and asking again cannot repair it: a second request is
absorbed by the build already in hand (#1707 slice 3).
The fix: a post-condition, checked where the compile SETTLES
latestReleasePathmust never be older thanlastCompiledVersionwhilerequestedReleaseAthas been consumed.
ReleasePostCondition (MeshWeaver.Compiler.Pipeline) evaluates it in the terminal chain of
RunCompile, after the release create has answered and before the terminal stamp β the one moment
at which every fact is in hand. The verdict is a pure function of (the definition at dispatch, the
compile result, the release cut on this settle), so it is unit-testable with no mesh.
It fires only on EVIDENCE, and stays silent wherever the answer is inconclusive:
| Condition | Verdict |
|---|---|
| A release was cut on this settle | holds |
The request was never made, or is still standing (handled < requested) |
not this check's business β the watcher re-fires a standing trigger itself |
| Consumed request, no release at all | violated |
| Consumed request, and the store version / assembly coordinates / compiled-source snapshot MOVED past the standing release | violated |
| Nothing in the result can distinguish the builds (a producer with no store and unchanged sources) | inconclusive β never a violation |
Why RE-CUT rather than only report
The remedy mints the missing release from the bytes this compile just produced β no recompile β under SYSTEM, with the requester cleared so it does not re-attempt the attribution that was refused.
- Reporting alone leaves the mesh in the incident's state: unrepairable from the outside,
because the trigger is spent and a fresh request is absorbed by the build in hand. Only
forceescaped, and the person who needed it could not write the node. - Un-consuming the trigger is worse. A release create that keeps failing would re-dispatch a compile on every settle β a reconcile fed by its own writes, unbounded.
- System is the credential that produced the bytes. The compile already runs as System precisely
so it succeeds on a partition the caller cannot write; the requester passed the
Compilegate at the entry point. Cutting the artefact that compile owed them widens nothing.
β¦and it stays LOUD
Silence is what made this invisible for a day, so the violation is an ERROR naming the type, the
stale path and the build that moved β whether or not the re-cut succeeds β and the outcome is
written to the compile _Activity, the official diagnosis surface. A re-cut that also fails says so
explicitly: "the node advertises a build no release names".
Pinned by: ReleasePostConditionTest (10 cases, MeshWeaver.Compiler.Pipeline.Test) for the
verdict, and β on a real mesh, in MeshWeaver.Plugins β ReleasePostConditionAtSettleTest, which
reproduces the incident end to end: an Editor who holds Compile asks for a release on a type whose
Release subtree denies Create, the compile succeeds as System, the attributed create is refused
and swallowed, and the settle must still leave a release naming lastCompiledVersion.