diff --git a/src/MeshWeaver.AI/AIExtensions.cs b/src/MeshWeaver.AI/AIExtensions.cs index 85f1bae689..cce9b89fa5 100644 --- a/src/MeshWeaver.AI/AIExtensions.cs +++ b/src/MeshWeaver.AI/AIExtensions.cs @@ -157,6 +157,15 @@ public static ITypeRegistry AddAITypes(this ITypeRegistry typeRegistry) // AgentConfiguration content children — serialised inside an Agent node's content. .WithType(typeof(AgentPluginReference), nameof(AgentPluginReference)) .WithType(typeof(AgentHandoff), nameof(AgentHandoff)) + // SkillAction: the BEHAVIOUR half of a Skill node's content (SkillDefinition.Action). + // SkillDefinition is registered (SkillNodeType → WithContentType) but its + // nested payload was not, so every hub serialised it via the resolver's unregistered + // fallback and logged "Unregistered type MeshWeaver.AI.SkillAction … auto-registered under + // its SHORT name". The auto short name is what is already persisted, so nameof() is the + // matching (non-breaking) registration — and registering it explicitly is what stops the + // discriminator depending on which hub happened to serialise the node first. Exactly the + // same reason AgentPluginReference / AgentHandoff are listed above. + .WithType(typeof(SkillAction), nameof(SkillAction)) // MessageViewModel is not registered — handled as JsonElement on the wire. // SubmitMessageRequest / SubmitMessageResponse deleted 2026-05-25: // the only mutation API is workspace.GetMeshNodeStream(path).Update(...). diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-installed-plugin-pages-stop-rendering-blank.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-installed-plugin-pages-stop-rendering-blank.md new file mode 100644 index 0000000000..9d13d6b425 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-installed-plugin-pages-stop-rendering-blank.md @@ -0,0 +1,21 @@ +--- +Name: Installed plugin pages stop rendering blank +Category: Fix +Description: Pages belonging to an installed plugin could come up empty, because the content one part of the portal had built could not be read by the rest of it. +Icon: Sparkle +Order: -20260809 +--- + +# Installed plugin pages stop rendering blank + +Plugins bring their own kinds of content with them — an underwriting guideline, a chess game, a +course. Those kinds are created by the portal itself when the plugin is installed, so only the part +of the portal that created them recognised them. Everywhere else the content arrived as raw data +that nothing knew how to display, and the page came up empty — sometimes reporting that it could not +find the view it was meant to show. + +Because a plugin's front page is itself one of these kinds of content, this could affect an entire +plugin at once rather than a single page inside it. + +The portal now shares what it knows about a plugin's content across all of its parts, so a page +renders wherever it is opened. diff --git a/src/MeshWeaver.Mesh.Contract/Services/IMeshContentTypeRegistry.cs b/src/MeshWeaver.Messaging.Hub/Serialization/IMeshContentTypeRegistry.cs similarity index 73% rename from src/MeshWeaver.Mesh.Contract/Services/IMeshContentTypeRegistry.cs rename to src/MeshWeaver.Messaging.Hub/Serialization/IMeshContentTypeRegistry.cs index dcc2c404b1..fb9af73ca6 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/IMeshContentTypeRegistry.cs +++ b/src/MeshWeaver.Messaging.Hub/Serialization/IMeshContentTypeRegistry.cs @@ -1,6 +1,14 @@ using System.Collections.Concurrent; using System.Text.Json; +// 🚨 Namespace deliberately NOT MeshWeaver.Messaging.Serialization (where the file now lives). +// The type used to sit in MeshWeaver.Mesh.Contract, but MeshWeaver.Mesh.Contract REFERENCES +// MeshWeaver.Messaging.Hub — so the wire-level degrade seam (ObjectPolymorphicConverter) could not +// see it, which is exactly the hole this registry exists to plug. Moving the FILE down (it has zero +// mesh dependencies — Type, JsonElement, JsonSerializerOptions) while KEEPING the namespace means +// every existing `using MeshWeaver.Mesh.Services;` still resolves. That matters more than tidiness +// here: NodeType source stored in the mesh is compiled at RUNTIME and is invisible to `dotnet build`, +// so renaming a public namespace is a breaking change no build or test in this repo can catch. namespace MeshWeaver.Mesh.Services; /// @@ -21,10 +29,23 @@ namespace MeshWeaver.Mesh.Services; /// /// This registry is the ONE mesh-wide source of truth for that mapping, independent of any /// hub's frozen options: populated once (at WithContentType) and retained for the process -/// lifetime, it lets the degrade seams (MeshNodeStreamCache, EnsureTypedContent) -/// recover the concrete type on the ALREADY-degraded path — no hot-path cost, no dependency on -/// per-hub option ordering. Registered in DI exactly like IAssemblyStore so it is reachable -/// from every hub's , including the cache hub. +/// lifetime, it lets the degrade seams recover the concrete type on the ALREADY-degraded path — no +/// hot-path cost, no dependency on per-hub option ordering. Registered in DI exactly like +/// IAssemblyStore so it is reachable from every hub's , +/// including the cache hub. +/// +/// The three seams that consult it, outermost first: +/// +/// ObjectPolymorphicConverter.ReadObject — the WIRE, and the broadest: EVERY hub that +/// receives such a node passes through it. It was the last one wired, which is why prod kept logging +/// "Received '$type':'PluginContent' which is NOT registered in this (receiving) hub" for every +/// plugin content type at once while the two seams below were already healing. +/// MeshNodeStreamCache (GetStream + GetQuery) — the cache hub's read boundary. +/// MeshNodeStreamHandle.EnsureTypedContent — the per-handle read boundary. +/// +/// None of them ADOPT the type into a hub's ITypeRegistry: they deserialise to the concrete +/// type explicitly, so a per-compile collectible identity never becomes a hub's long-lived answer for +/// that discriminator (see PolymorphicTypeInfoResolver.WarnCollectibleSerialization). /// public interface IMeshContentTypeRegistry { diff --git a/src/MeshWeaver.Messaging.Hub/Serialization/ObjectPolymorphicConverter.cs b/src/MeshWeaver.Messaging.Hub/Serialization/ObjectPolymorphicConverter.cs index d1a58fb9d1..cf881677b6 100644 --- a/src/MeshWeaver.Messaging.Hub/Serialization/ObjectPolymorphicConverter.cs +++ b/src/MeshWeaver.Messaging.Hub/Serialization/ObjectPolymorphicConverter.cs @@ -12,7 +12,8 @@ namespace MeshWeaver.Messaging.Serialization; public class ObjectPolymorphicConverter( ITypeRegistry typeRegistry, ILogger? logger = null, - SerializationDepthGuard? depthGuard = null) : JsonConverter + SerializationDepthGuard? depthGuard = null, + MeshWeaver.Mesh.Services.IMeshContentTypeRegistry? contentTypeRegistry = null) : JsonConverter { // Depth accounting across the NESTED serializer sessions this converter spawns (see // SerializationDepthGuard): without it a self-referencing graph recurses one C# stack level @@ -150,10 +151,29 @@ or InvalidOperationException } else if (!string.IsNullOrEmpty(typeName)) { - // The payload carries a $type but THIS (receiving) hub has no registration for it, so it can - // only be read back as an untyped JsonElement (renders empty / reactive waits time out — the - // chat-vanish / untyped-storm class). A type must be registered in the RECEIVING hub as well - // as the sending one; warn once per type so the missing registration is found fast. + // 🚨 Self-heal BEFORE warning: a dynamically-compiled NodeType's content type can never be + // registered on this hub the static way. PolymorphicTypeInfoResolver deliberately refuses to + // adopt collectible-assembly types into a long-lived per-hub TypeRegistry (a per-compile CLR + // identity would poison every later $type resolution and pin the assembly), so the ONLY hub + // that can resolve "$type":"PluginContent" / "GuidelineReference" is the one whose + // MeshDataSource.WithContentType declared it. Every OTHER hub that receives the same node — + // the domain-agnostic cache hub, the routing hub, a sibling per-node hub — degraded it to a + // bare JsonElement here, and downstream `Content is X` / `as X` then silently failed + // ("renders empty", layout areas "cannot be found"). + // + // IMeshContentTypeRegistry is the mesh-wide, options-independent $type→CLR-Type map that + // exists for exactly this. It was consulted at the two MeshNodeStreamCache seams but NOT + // here — the FIRST place the degrade happens — so a type the process demonstrably knew still + // arrived untyped. Deserialising to the concrete type explicitly is not "adopting" it: no + // registry entry is created on this hub, so none of the stale-identity/pinning reasoning + // above is violated. It is the same recovery ContentAs performs at the consumer, done + // once, centrally, instead of being every consumer's problem. + var recovered = contentTypeRegistry?.TryRecover(cleanedElement, options); + if (recovered is not null) + return recovered; + + // Genuinely unresolvable anywhere in the process: a type must be registered in the RECEIVING + // hub as well as the sending one. Warn once per type so the missing registration is found fast. WarnUnregisteredDeserialization(typeName!); } } diff --git a/src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs b/src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs index adb91064b6..d32ea77949 100644 --- a/src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs +++ b/src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs @@ -119,9 +119,15 @@ private static IEnumerable GetStandardConverters(IMessageHub hub) // native stack is exhausted (StackOverflow → SIGABRT — uncatchable, kills the process). var depthGuard = new SerializationDepthGuard(); yield return new AddressConverter(); + // The mesh-wide $type→CLR-Type map (IMeshContentTypeRegistry) lets the converter re-type content + // whose type this hub cannot register statically — a dynamically-compiled NodeType's content. + // GetService, not GetRequiredService: hosts without the mesh persistence stack (bare + // Messaging.Hub tests) register none, and a null registry leaves the converter's behaviour + // exactly as it was. yield return new ObjectPolymorphicConverter(hub.TypeRegistry, hub.ServiceProvider.GetService>(), - depthGuard); + depthGuard, + hub.ServiceProvider.GetService()); yield return new MessageDeliveryConverter(hub.TypeRegistry); yield return new ReadOnlyCollectionConverterFactory(); yield return new JsonNodeConverter(); diff --git a/test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs b/test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs index d86dcf3bad..eefa2bd68d 100644 --- a/test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs +++ b/test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs @@ -5,6 +5,8 @@ using MeshWeaver.Fixture; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace MeshWeaver.Hosting.Test; @@ -151,6 +153,67 @@ public void EnsureTypedContent_Seam_RecoversViaRegistry_DegradesWithout() "the seam must round-trip the payload, not merely the type tag"); } + /// + /// 🚨 The WIRE seam — the FIRST place a dynamic NodeType's content degrades, and until now the only + /// one that never consulted the mesh-wide registry. + /// + /// Prod (memex.meshweaver.cloud, 2026-08-09) logged this continuously, for EVERY plugin's + /// content type at once — PluginContent (the type of every Store/Plugin ROOT node, so + /// /Underwriting itself), GuidelineReference, RegionalLimitContent, + /// CapacityCapContent, AppetiteMetricContent, Workbench, ChessGame, …: + /// "Received '$type':'PluginContent' which is NOT registered in this (receiving) hub". The + /// advice in that warning — register it on the receiving hub — is IMPOSSIBLE for a type that only + /// exists after a runtime Roslyn compile, and PolymorphicTypeInfoResolver deliberately + /// refuses to auto-adopt collectible types into a per-hub registry. So the node crossed the wire + /// untyped, every Content is X soft-cast failed, and the page rendered empty. + /// + /// Two hubs, ONE payload, the only difference being whether the process-wide + /// is reachable from the receiving hub's DI: without it the + /// bug reproduces, with it the converter re-types. Delete the registry lookup from + /// ObjectPolymorphicConverter.ReadObject and this turns red. + /// + [Fact] + public void WireSeam_ForeignHubReadsDynamicContentTyped_OnlyThroughRegistry() + { + var contentType = EmitCollectibleType("WirePluginContent", "Body"); + var registry = new MeshContentTypeRegistry(); + registry.Register(contentType); + + // A hub that does NOT own the type and has NO mesh-wide registry — the shape every + // pre-fix receiving hub had (cache hub, routing hub, a sibling per-node hub). + var unaidedHub = GetClient(); + var instance = Activator.CreateInstance(contentType)!; + contentType.GetProperty("Body")!.SetValue(instance, "hero markup"); + var stored = JsonSerializer.Serialize(instance, contentType, unaidedHub.JsonSerializerOptions); + stored.Should().Contain(contentType.Name, "the owning hub stamps the short-name $type discriminator"); + + // BUG REPRODUCED at the wire seam: the receiving hub's polymorphic converter cannot resolve the + // discriminator, so Content arrives as a bare JsonElement. + JsonSerializer.Deserialize(stored, unaidedHub.JsonSerializerOptions) + .Should().BeOfType( + "a receiving hub with no registration and no mesh-wide registry degrades dynamic content to JsonElement"); + + // FIX: the SAME payload, read by a hub whose DI exposes the mesh-wide registry — the converter + // recovers the concrete CLR type. No per-hub WithType, no recycle, no adoption into the hub's + // own TypeRegistry (so the collectible assembly is not pinned by this hub). + var aidedHub = GetClient(c => c + .WithServices(s => s.AddSingleton(registry)) + .WithPostingIdentity(PostingIdentity.System)); + + var recovered = JsonSerializer.Deserialize(stored, aidedHub.JsonSerializerOptions); + + recovered!.GetType().Should().Be(contentType, + "the wire seam must consult the mesh-wide registry before degrading to JsonElement"); + contentType.GetProperty("Body")!.GetValue(recovered).Should().Be("hero markup", + "recovery must round-trip the payload, not merely the type tag"); + + // The receiving hub must NOT have adopted the collectible type: adopting a per-compile CLR + // identity into a long-lived registry is what PolymorphicTypeInfoResolver refuses to do, and + // the fix must not smuggle it in through the back door. + aidedHub.TypeRegistry.TryGetType(contentType.Name, out _).Should().BeFalse( + "recovery deserialises to the concrete type; it must not register the collectible type on the hub"); + } + /// /// Emits a minimal public class with one string property into a COLLECTIBLE dynamic assembly — /// the CLR shape of a compiled dynamic-node content type without dragging Roslyn into the test.