diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-25-require-prebuilt-first-access.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-25-require-prebuilt-first-access.md new file mode 100644 index 0000000000..652a5606a7 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-25-require-prebuilt-first-access.md @@ -0,0 +1,24 @@ +--- +Name: A module type that was never baked now says so, instead of compiling in secret +Category: Fix +Description: On a mesh that requires prebuilt assemblies, opening a module type whose build never landed no longer triggers a silent on-mesh compile — the type parks with a clear message naming the missing bundle, the framework lane and the fix, and every page of that type shows it. +Icon: ShieldError +Order: -20260825 +--- + +# A module type that was never baked now says so, instead of compiling in secret + +`Modules:RequirePrebuilt` already refused to compile when a package's bundle could not be fetched +at install time. The deeper case — a type that is simply *touched* later, on first access, with no +adopted assembly — still slipped into a Roslyn compile, because that path runs through the +type's own compile watcher rather than the install lane. + +It no longer does. On a require-prebuilt mesh, any trigger that would compile such a type — first +access, a release request, a self-heal, a framework-stale rebuild — parks it instead, with one +message: which type, which framework identity and architecture the assembly is missing for, which +package's bundle publishes it, and how to retry once it lands. Every page of that type renders the +reason through the usual compilation-error overlay instead of waiting on a compile that was never +going to be allowed, and the park keeps the refusal bounded — no storm, no retry loop, zero +compiles started. + +Meshes that do not set the flag are unchanged: they compile exactly as before. diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs index 69fb6b2863..645a2085b0 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs @@ -219,22 +219,16 @@ public static IDisposable InstallCompileWatcher( // through InstallReleaseRequestWatcher, which un-parks BEFORE promoting to // Pending — so this guard never blocks a legitimate retry, only a stray // re-trigger (a persisted-Compiling recovery kickoff, a legacy direct flip). - if (parkRegistry?.IsParked(hubPath) == true) + // 🅿️ Wedge-close: a Pending flip we refuse to dispatch was already WRITTEN to + // the node. Without a settle write-back the type would sit at Pending forever — + // every settle-waiter (get_diagnostics' / the compile tool's Where(Ok|Error), + // WaitForLatestRelease) hangs to its timeout and the stray trigger never + // reaches a sink. Re-settle Pending → Error with the given reason so the + // trigger is answered (bounded, no Roslyn). Same fire-and-forget UpdateOwn + // shape as the kickoffs below; System scope because re-settling framework + // state is infrastructure, not a user write. + void SettleAsError(string? reason) { - logger?.LogDebug( - "Compile watcher: {HubPath} is PARKED (terminal compile failure) — " + - "skipping recompile, serving cached error", hubPath); - // 🅿️ Wedge-close: the Pending flip we refuse to dispatch was already - // WRITTEN to the node. Without a settle write-back the type would sit - // at Pending forever — every settle-waiter (get_diagnostics' / the - // compile tool's Where(Ok|Error), WaitForLatestRelease) hangs to its - // timeout and the stray trigger never reaches a sink. Re-settle - // Pending → Error with the CACHED parked error so the trigger is - // answered (bounded, no Roslyn) while the park stays in place. Same - // fire-and-forget UpdateOwn shape as the kickoffs below; System scope - // because re-settling framework state is infrastructure, not a user - // write. - var parkedError = parkRegistry.GetParkedError(hubPath); using (accessService?.ImpersonateAsSystem()) workspace.GetMeshNodeStream().Update(curr => { @@ -250,7 +244,7 @@ public static IDisposable InstallCompileWatcher( Content = parkedDef with { CompilationStatus = CompilationStatus.Error, - CompilationError = parkedError + CompilationError = reason ?? parkedDef.CompilationError ?? "Compilation is parked after a terminal failure; request a release (Compile) to retry." } @@ -260,6 +254,54 @@ public static IDisposable InstallCompileWatcher( ex => logger?.LogWarning(ex, "Compile watcher: failed to re-settle parked {HubPath} from Pending to Error", hubPath)); + } + + if (parkRegistry?.IsParked(hubPath) == true) + { + logger?.LogDebug( + "Compile watcher: {HubPath} is PARKED (terminal compile failure) — " + + "skipping recompile, serving cached error", hubPath); + SettleAsError(parkRegistry.GetParkedError(hubPath)); + return; + } + + // 🚨 THE ADOPT-ONLY GATE (Modules:RequirePrebuilt, MeshWeaver#2193 §A). On a + // mesh that requires prebuilt assemblies, a Pending flip on a type WITHOUT a + // usable build — first access, a release request, a self-heal kick, a + // framework-stale rebuild — must never reach Roslyn. Every route into a compile + // passes through this handler (a deliberate retry un-parks and then lands + // HERE), so this is the one place that turns "compile on a miss" into a + // PARKED, named refusal: the type settles at Error with a message that says + // what is missing (the assembly for this identity/architecture), what + // publishes it (the package's bundle for this lane) and how to retry — and + // every instance page renders that reason through the compilation-error + // overlay instead of hanging on a compile that would not have been allowed. + // Parking (deterministic — the same miss reproduces until a bundle lands) + // keeps the refusal bounded and visible, exactly like a terminal source error; + // the park registry's attempt counter stays at ZERO, the observable proof no + // Roslyn pass ever started. + if (PrebuiltAssemblySeeder.RequirePrebuilt(hub.ServiceProvider)) + { + var reason = PrebuiltAssemblySeeder.RequiredParkReason(hubPath); + logger?.LogError( + "Compile watcher: {HubPath} has no adopted assembly and this mesh sets {Key} — " + + "PARKING with a named refusal instead of compiling. {Reason}", + hubPath, PrebuiltAssemblySeeder.RequirePrebuiltConfigKey, reason); + // The registry is resolved from the hub's services HERE, exactly as the + // real dispatch does — the watcher's own handle is only ever probed + // null-safely and must not be the thing the park depends on. The park + // carries the type's CURRENT source snapshot so the sources watcher does + // not read "sources changed since the park" against a null baseline and + // un-park into a park/un-park ping-pong; a genuine later source edit + // still re-drives (and is refused again, named, by this gate). + var registry = hub.ServiceProvider.GetService() + ?? parkRegistry; + var pendingDef = pendingNode!.ContentAs( + hub.JsonSerializerOptions, logger); + registry?.OnCompileFailed( + hub, hubPath, reason, deterministic: true, + recipientUserId: null, sources: pendingDef?.CurrentSourceVersions, logger); + SettleAsError(reason); return; } diff --git a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs index aae65c23b8..622c4400e7 100644 --- a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs +++ b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs @@ -53,6 +53,24 @@ public static bool RequirePrebuilt(IServiceProvider? services) } } + /// + /// The NAMED refusal a require-prebuilt mesh parks a NodeType with when it would otherwise + /// compile on a miss (the compile watcher's adopt-only gate): what is missing, for which + /// lane, what publishes it, how to retry. One shape for the whole family — the same facts the + /// install-lane names — so an operator reads the same + /// sentence whichever seam refused. The package is the type's partition root (the node-repo + /// layout: {Package}/{Type}). Pure. + /// + public static string RequiredParkReason(string nodeTypePath) + { + var slash = nodeTypePath.IndexOf('/'); + var package = slash > 0 ? nodeTypePath[..slash] : nodeTypePath; + return $"{RequirePrebuiltConfigKey}: NodeType '{nodeTypePath}' has no adopted assembly for " + + $"framework {LiveFrameworkMvid}/{ReleaseArchitecture.Live}, and this mesh does not " + + $"compile module content. Publish or rebake package '{package}' for this framework " + + "identity and architecture, then request a release to retry (MeshWeaver#2193 §A)."; + } + /// /// Why a prebuilt assembly may NOT be adopted, or null when it may. /// diff --git a/test/MeshWeaver.Hosting.Monolith.Test/RequirePrebuiltParkTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/RequirePrebuiltParkTest.cs new file mode 100644 index 0000000000..a61691468d --- /dev/null +++ b/test/MeshWeaver.Hosting.Monolith.Test/RequirePrebuiltParkTest.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using MeshWeaver.Data; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +#pragma warning disable CS1591 + +namespace MeshWeaver.Hosting.Monolith.Test; + +/// +/// 🚨 The adopt-only gate on FIRST ACCESS (MeshWeaver#2193 §A, the deepest seam): on a mesh +/// that sets Modules:RequirePrebuilt, a NodeType touched without an adopted assembly must +/// PARK with a named refusal — never enter Roslyn. +/// +/// The shape pinned here is the one nobody can observe from the install lane: a perfectly +/// valid type (its configuration compiles fine anywhere else) whose bundle simply never landed +/// for this identity. The first-build kickoff flips it to Pending; the compile watcher's gate +/// must then (a) settle it at Error with a message naming the policy key, the type, the +/// framework identity and the fix, (b) park it so no later trigger can storm, and (c) — the +/// observable proof — never start a compile: the park registry's attempt counter stays at ZERO +/// (it increments only inside the real dispatch). The flag-off contract is the whole existing +/// suite, where the same type compiles. +/// +public class RequirePrebuiltParkTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private const string Partition = "RequirePrebuiltTest"; + private const string NodeTypeId = "NeverBaked"; + private const string NodeTypePath = $"{Partition}/{NodeTypeId}"; + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => + base.ConfigureMesh(builder) + .ConfigureServices(services => + // The deployment opt-in under test — the same in-memory registration idiom the + // Content tests use. + services.AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PrebuiltAssemblySeeder.RequirePrebuiltConfigKey] = "true", + }) + .Build())); + + [Fact(Timeout = 120_000)] + public async Task AValidTypeWithNoAdoptedAssembly_ParksWithTheNamedRefusal_AndNeverCompiles() + { + var workspace = Mesh.GetWorkspace(); + var parkRegistry = Mesh.ServiceProvider.GetRequiredService(); + + // A type that WOULD compile anywhere the flag is off — the point is that it is refused + // for lack of a bundle, not for lack of correctness. + await NodeFactory.CreateNode(new MeshNode(NodeTypeId, Partition) + { + Name = "Never baked type", + NodeType = MeshNode.NodeTypePath, + Content = new NodeTypeDefinition + { + Description = "Valid configuration, no prebuilt assembly for this lane.", + Configuration = "config => config", + } + }).Should().Emit(); + + // (a) It settles at Error with the NAMED refusal — never sits at Pending, never hangs. + var settled = await workspace.GetMeshNodeStream(NodeTypePath) + .Should().Within(60.Seconds()) + .Match(n => n.Content is NodeTypeDefinition d + && d.CompilationStatus == CompilationStatus.Error); + var def = (NodeTypeDefinition)settled.Content!; + def.CompilationError.Should().NotBeNull(); + def.CompilationError.Should().Contain(PrebuiltAssemblySeeder.RequirePrebuiltConfigKey, + "the refusal must name the policy that refused"); + def.CompilationError.Should().Contain(NodeTypePath, "…and the type"); + def.CompilationError.Should().Contain(PrebuiltAssemblySeeder.LiveFrameworkMvid, + "…and the lane the assembly is missing for"); + def.CompilationError.Should().Contain($"'{Partition}'", + "…and the package whose bundle would fix it"); + + // (b) PARKED — bounded and visible, like any terminal failure. + parkRegistry.IsParked(NodeTypePath).Should().BeTrue( + "a require-prebuilt refusal must park the type so no later trigger can storm"); + parkRegistry.GetParkedError(NodeTypePath).Should().Contain( + PrebuiltAssemblySeeder.RequirePrebuiltConfigKey); + + // (c) NO COMPILE EVER STARTED — the counter increments only inside the real dispatch. + parkRegistry.GetCompileAttemptCount(NodeTypePath).Should().Be(0, + "the adopt-only gate refuses BEFORE Roslyn; a single attempt would mean it compiled"); + } +}