Repository navigation
Plugin content read untyped on every hub but the one that compiled it - #1057
Merged
Merged
Conversation
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<T> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPQKMhQDpitUzsW1MqTVhH
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a production issue where dynamically-compiled plugin content (e.g., PluginContent) crossed hub boundaries and degraded to untyped JsonElement on receiving hubs, causing plugin pages to render blank. It does this by consulting the process-wide IMeshContentTypeRegistry at the earliest “wire seam” during polymorphic deserialization, so hubs can deserialize to the concrete CLR type without adopting collectible types into a per-hub registry.
Changes:
- Wire
IMeshContentTypeRegistryintoObjectPolymorphicConverterso the wire-level polymorphic deserialization can recover known dynamic content types before degrading toJsonElement. - Move
IMeshContentTypeRegistryintoMeshWeaver.Messaging.Hub(keeping the original namespace) to break the dependency-direction constraint and make it available at the wire seam. - Add coverage: a new hosting test that reproduces the “foreign hub reads dynamic content untyped” behavior and verifies recovery via registry + non-adoption into the receiving hub’s
TypeRegistry; plus registerSkillActioninAddAITypesand add a What’s New entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/ReimportTypedContentRecoveryTest.cs | Adds a regression test that reproduces the wire-seam degradation and validates recovery via the mesh-wide registry without per-hub type adoption. |
| src/MeshWeaver.Messaging.Hub/SerializationExtensions.cs | Injects the mesh-wide content type registry into the standard ObjectPolymorphicConverter used by hubs. |
| src/MeshWeaver.Messaging.Hub/Serialization/ObjectPolymorphicConverter.cs | Adds registry-based recovery at the wire seam before warning/degrading to JsonElement. |
| src/MeshWeaver.Messaging.Hub/Serialization/IMeshContentTypeRegistry.cs | Relocates the registry interface/implementation into Messaging.Hub while preserving its original namespace to avoid breaking runtime-compiled node sources. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-installed-plugin-pages-stop-rendering-blank.md | Documents the user-visible fix for installed plugin pages rendering blank. |
| src/MeshWeaver.AI/AIExtensions.cs | Registers SkillAction in AddAITypes to stabilize $type handling for nested Skill payloads. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+171
to
177
| 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!); |
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
This was referenced Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug, from prod
memex.meshweaver.cloudlogs this continuously — for every installed plugin's content type at once:Loki, last 72 h,
{namespace="memex-cloud"}, distinct types:PluginContent(14×, the most frequent),GuidelineReference,RegionalLimitContent,CapacityCapContent,AppetiteMetricContent,Workbench,ChessGame,StoreContent,InstallManifest,CourseInviteContent,TourContent,QuizContent,NBodyContent, … — plus the matchingMeshNodeStreamCache.GetQuery: Content for Underwriting/Rulebook/Regions/Japan stayed an untyped JsonElement.PluginContentis the content type of everyStore/PluginROOT node, so this takes out a plugin's front page —/Underwriting— not merely a leaf inside it.Why the warning's own advice cannot be followed
Every one of those types is a dynamically-compiled NodeType content type — they only exist after a runtime Roslyn compile, in a collectible assembly. None of them lives in this repo (the Underwriting ones are node sources in
MeshWeaver.Reinsurance,PluginContent/StoreContent/InstallManifestinMeshWeaver.Plugins), so no hub canWithTypethem, andPolymorphicTypeInfoResolverdeliberately refuses to auto-adopt collectible types into a per-hub registry — a per-compile CLR identity would poison every later$typeresolution on that hub and pin the assembly.So only the hub whose
MeshDataSource.WithContentTypedeclared the type can resolve it. Every other hub receiving the same node — the domain-agnostic cache hub, the routing hub, a sibling per-node hub — degradedContentto a bareJsonElement, and every downstreamContent is X/as Xsilently failed.The gap
IMeshContentTypeRegistry— the mesh-wide, options-independent$type→ CLR-Typemap — exists for exactly this. It was consulted at three places… no: at two (MeshNodeStreamCacheGetStream/GetQuery) plusEnsureTypedContent. It was not consulted at the wire:ObjectPolymorphicConverter.ReadObject, the first and broadest seam, which every receiving hub passes through. Content whose type the process demonstrably knew still arrived untyped.The fix
ContentAs<T>performs at the consumer, done once and centrally. The test asserts the receiving hub'sTypeRegistrystays clean, so the collectible-identity/pinning reasoning is not violated.IMeshContentTypeRegistryfromMeshWeaver.Mesh.ContracttoMeshWeaver.Messaging.Hub—Mesh.ContractreferencesMessaging.Hub, so the wire seam could not see it. The type has zero mesh dependencies. The namespaceMeshWeaver.Mesh.Servicesis kept: in-mesh NodeType source compiles at runtime and is invisible todotnet build, so a public namespace rename is a break no build or test in this repo can catch.MeshWeaver.AI.SkillActiononAddAITypes—SkillDefinition.Action's payload, sibling of the already-registeredAgentPluginReference/AgentHandoff. It was serialising through the resolver's unregistered fallback (Unregistered type MeshWeaver.AI.SkillAction … auto-registered under its SHORT name). The auto short name is what is already persisted, sonameof()is the matching, non-breaking registration.Test
WireSeam_ForeignHubReadsDynamicContentTyped_OnlyThroughRegistry— two hubs, one payload, the only difference being whether the mesh-wide registry is reachable from the receiving hub's DI. The dynamic content type is a collectibleReflection.Emittype (same CLR shape a NodeType compile produces) so the defect reproduces without dragging Roslyn into a unit test.Verified it is a pin, not an assertion: removing the DI wiring turns it red —
Verification
dotnet build -c Release -warnaserror:MeshWeaver.Messaging.Hub,MeshWeaver.AI,MeshWeaver.Hosting.Test,MeshWeaver.Messaging.Hub.Test,MeshWeaver.AI.Test— 0 warnings, 0 errors.MeshWeaver.Hosting.Test132/132 ·MeshWeaver.Messaging.Hub.Test161/161.Known residual (not fixed here, called out deliberately)
The registry is still populated only as a side effect of
MeshDataSource.WithContentType, which runs when a hub is built with a NodeType's instance configuration. A query-driven listing never builds one, so a NodeType whose instances have never been visited in this process is still unknown to the whole process —NodeTypeConfiguration.DataTypeis stubbed totypeof(object)on the compile path, so the mapping genuinely isn't available earlier without probing. This PR makes the mapping usable everywhere once it is known (which is what the prodPluginContentstorm was); closing the population gap is a separate, larger change.🤖 Generated with Claude Code
https://claude.ai/code/session_01CPQKMhQDpitUzsW1MqTVhH