From 32883fc701b57d82ff2ba56851992caecd58e872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 9 Aug 2026 23:39:32 +0200 Subject: [PATCH] Plugin content read untyped on every hub but the one that compiled it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memex.meshweaver.cloud logs, continuously, for EVERY installed plugin's content type at once: Received '$type':'PluginContent' which is NOT registered in this (receiving) hub — it can only be read as an untyped JsonElement (the value renders empty) and the same for GuidelineReference, RegionalLimitContent, CapacityCapContent, AppetiteMetricContent, Workbench, ChessGame, StoreContent, InstallManifest, CourseInviteContent… PluginContent is the content type of every Store/Plugin ROOT node, so this takes out a plugin's front page — /Underwriting — not just a leaf. The warning's own advice is impossible to follow here: these types only exist after a runtime Roslyn compile, so no hub can WithType them, and PolymorphicTypeInfoResolver deliberately refuses to auto-adopt collectible types into a per-hub registry (a per-compile CLR identity would poison every later $type resolution on that hub and pin the assembly). Only the hub whose MeshDataSource.WithContentType declared the type can resolve it; every other hub that receives the node degrades Content to a bare JsonElement, and downstream `Content is X` / `as X` silently fails. IMeshContentTypeRegistry — the mesh-wide, options-independent $type→CLR-Type map — exists for exactly this, and was consulted at the two MeshNodeStreamCache seams and at EnsureTypedContent. It was NOT consulted at the wire: ObjectPolymorphicConverter .ReadObject, the FIRST and broadest seam, which every receiving hub passes through. So content whose type the process demonstrably knew still arrived untyped. Consult it there too, before warning. This is not adoption — no entry is created on the receiving hub, it deserialises to the concrete type explicitly, the same recovery ContentAs does at the consumer, done once and centrally. The registry lived in MeshWeaver.Mesh.Contract, which REFERENCES MeshWeaver.Messaging.Hub, so the wire seam could not see it. Moved the file down (it has zero mesh dependencies) and KEPT the namespace MeshWeaver.Mesh.Services: in-mesh NodeType source compiles at runtime and is invisible to dotnet build, so a public namespace rename is a break no build or test here can catch. Also registers MeshWeaver.AI.SkillAction on AddAITypes — SkillDefinition.Action's payload, the sibling of AgentPluginReference/AgentHandoff, which was serialising through the resolver's unregistered fallback ("auto-registered under its SHORT name"). nameof() matches the short name already persisted, so it is non-breaking. Test: WireSeam_ForeignHubReadsDynamicContentTyped_OnlyThroughRegistry — two hubs, one payload, the only difference being whether the registry is reachable from the receiving hub's DI. Verified it goes RED when the DI wiring is removed, and it asserts the receiving hub does NOT adopt the collectible type. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CPQKMhQDpitUzsW1MqTVhH --- src/MeshWeaver.AI/AIExtensions.cs | 9 +++ ...alled-plugin-pages-stop-rendering-blank.md | 21 +++++++ .../IMeshContentTypeRegistry.cs | 29 +++++++-- .../ObjectPolymorphicConverter.cs | 30 +++++++-- .../SerializationExtensions.cs | 8 ++- .../ReimportTypedContentRecoveryTest.cs | 63 +++++++++++++++++++ 6 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-installed-plugin-pages-stop-rendering-blank.md rename src/{MeshWeaver.Mesh.Contract/Services => MeshWeaver.Messaging.Hub/Serialization}/IMeshContentTypeRegistry.cs (73%) 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.