Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/MeshWeaver.AI/AIExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SkillDefinition>) 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(...).
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
Expand All @@ -21,10 +29,23 @@ namespace MeshWeaver.Mesh.Services;
///
/// <para>This registry is the ONE mesh-wide source of truth for that mapping, independent of any
/// hub's frozen options: populated once (at <c>WithContentType</c>) and retained for the process
/// lifetime, it lets the degrade seams (<c>MeshNodeStreamCache</c>, <c>EnsureTypedContent</c>)
/// 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 <c>IAssemblyStore</c> so it is reachable
/// from every hub's <see cref="System.IServiceProvider"/>, including the cache hub.</para>
/// 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
/// <c>IAssemblyStore</c> so it is reachable from every hub's <see cref="System.IServiceProvider"/>,
/// including the cache hub.</para>
///
/// <para><b>The three seams that consult it</b>, outermost first:
/// <list type="number">
/// <item><c>ObjectPolymorphicConverter.ReadObject</c> — 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
/// <c>"Received '$type':'PluginContent' which is NOT registered in this (receiving) hub"</c> for every
/// plugin content type at once while the two seams below were already healing.</item>
/// <item><c>MeshNodeStreamCache</c> (GetStream + GetQuery) — the cache hub's read boundary.</item>
/// <item><c>MeshNodeStreamHandle.EnsureTypedContent</c> — the per-handle read boundary.</item>
/// </list>
/// None of them ADOPT the type into a hub's <c>ITypeRegistry</c>: 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 <c>PolymorphicTypeInfoResolver.WarnCollectibleSerialization</c>).</para>
/// </summary>
public interface IMeshContentTypeRegistry
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ namespace MeshWeaver.Messaging.Serialization;
public class ObjectPolymorphicConverter(
ITypeRegistry typeRegistry,
ILogger? logger = null,
SerializationDepthGuard? depthGuard = null) : JsonConverter<object>
SerializationDepthGuard? depthGuard = null,
MeshWeaver.Mesh.Services.IMeshContentTypeRegistry? contentTypeRegistry = null) : JsonConverter<object>
{
// Depth accounting across the NESTED serializer sessions this converter spawns (see
// SerializationDepthGuard): without it a self-referencing graph recurses one C# stack level
Expand Down Expand Up @@ -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<T> 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!);
Comment on lines +171 to 177
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,15 @@ private static IEnumerable<JsonConverter> 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<ILogger<ObjectPolymorphicConverter>>(),
depthGuard);
depthGuard,
hub.ServiceProvider.GetService<MeshWeaver.Mesh.Services.IMeshContentTypeRegistry>());
yield return new MessageDeliveryConverter(hub.TypeRegistry);
yield return new ReadOnlyCollectionConverterFactory();
yield return new JsonNodeConverter();
Expand Down
63 changes: 63 additions & 0 deletions test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -151,6 +153,67 @@ public void EnsureTypedContent_Seam_RecoversViaRegistry_DegradesWithout()
"the seam must round-trip the payload, not merely the type tag");
}

/// <summary>
/// 🚨 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.
///
/// <para>Prod (memex.meshweaver.cloud, 2026-08-09) logged this continuously, for EVERY plugin's
/// content type at once — <c>PluginContent</c> (the type of every Store/Plugin ROOT node, so
/// <c>/Underwriting</c> itself), <c>GuidelineReference</c>, <c>RegionalLimitContent</c>,
/// <c>CapacityCapContent</c>, <c>AppetiteMetricContent</c>, <c>Workbench</c>, <c>ChessGame</c>, …:
/// <c>"Received '$type':'PluginContent' which is NOT registered in this (receiving) hub"</c>. 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 <c>PolymorphicTypeInfoResolver</c> deliberately
/// refuses to auto-adopt collectible types into a per-hub registry. So the node crossed the wire
/// untyped, every <c>Content is X</c> soft-cast failed, and the page rendered empty.</para>
///
/// <para>Two hubs, ONE payload, the only difference being whether the process-wide
/// <see cref="IMeshContentTypeRegistry"/> is reachable from the receiving hub's DI: without it the
/// bug reproduces, with it the converter re-types. Delete the registry lookup from
/// <c>ObjectPolymorphicConverter.ReadObject</c> and this turns red.</para>
/// </summary>
[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<object>(stored, unaidedHub.JsonSerializerOptions)
.Should().BeOfType<JsonElement>(
"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<IMeshContentTypeRegistry>(registry))
.WithPostingIdentity(PostingIdentity.System));

var recovered = JsonSerializer.Deserialize<object>(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");
}

/// <summary>
/// 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.
Expand Down
Loading