Skip to content

Plugin content read untyped on every hub but the one that compiled it - #1057

Merged
rbuergi merged 1 commit into
mainfrom
fix/dynamic-nodetype-wire-typing
Aug 10, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/dynamic-nodetype-wire-typing

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The bug, from prod

memex.meshweaver.cloud logs this 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)

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 matching MeshNodeStreamCache.GetQuery: Content for Underwriting/Rulebook/Regions/Japan stayed an untyped JsonElement.

PluginContent is the content type of every Store/Plugin ROOT 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/InstallManifest in MeshWeaver.Plugins), 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.

So only the hub whose MeshDataSource.WithContentType declared 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 — degraded Content to a bare JsonElement, and every downstream Content is X / as X silently failed.

The gap

IMeshContentTypeRegistry — the mesh-wide, options-independent $type → CLR-Type map — exists for exactly this. It was consulted at three places… no: at two (MeshNodeStreamCache GetStream/GetQuery) plus EnsureTypedContent. 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

  1. Consult the registry at the wire seam, 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> performs at the consumer, done once and centrally. The test asserts the receiving hub's TypeRegistry stays clean, so the collectible-identity/pinning reasoning is not violated.
  2. Moved IMeshContentTypeRegistry from MeshWeaver.Mesh.Contract to MeshWeaver.Messaging.Hub — Mesh.Contract references Messaging.Hub, so the wire seam could not see it. The type has zero mesh dependencies. The namespace MeshWeaver.Mesh.Services is kept: 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 in this repo can catch.
  3. Registers MeshWeaver.AI.SkillAction on AddAITypes — SkillDefinition.Action's payload, sibling of the already-registered AgentPluginReference/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, so nameof() 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 collectible Reflection.Emit type (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 —

Expected value to be WirePluginContent because the wire seam must consult the
mesh-wide registry before degrading to JsonElement, but found JsonElement.

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.Test 132/132 · MeshWeaver.Messaging.Hub.Test 161/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.DataType is stubbed to typeof(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 prod PluginContent storm was); closing the population gap is a separate, larger change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CPQKMhQDpitUzsW1MqTVhH

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
Copilot AI lite review requested due to automatic review settings August 9, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IMeshContentTypeRegistry into ObjectPolymorphicConverter so the wire-level polymorphic deserialization can recover known dynamic content types before degrading to JsonElement.
  • Move IMeshContentTypeRegistry into MeshWeaver.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 register SkillAction in AddAITypes and 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!);
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

690 tests  ±0   690 ✅ +1   13m 20s ⏱️ +31s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌  - 1 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 284 tests  ±0   1 279 ✅ ±0   6m 39s ⏱️ -14s
   11 suites ±0       5 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 946 tests  +1   1 944 ✅ +1   6m 14s ⏱️ -9s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 202 tests  ±0   1 202 ✅ ±0   6m 26s ⏱️ + 1m 10s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 725 tests  ±0   1 533 ✅ ±0   5m 47s ⏱️ -11s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 59s ⏱️ +58s
1 976 tests ±0  1 868 ✅ ±0  108 💤 ±0  0 ❌ ±0 
2 121 runs  +1  2 013 ✅ +1  108 💤 ±0  0 ❌ ±0 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   44m 28s ⏱️ + 2m 6s
8 823 tests +1  8 516 ✅ +2  307 💤 ±0  0 ❌  - 1 
8 968 runs  +2  8 661 ✅ +3  307 💤 ±0  0 ❌  - 1 

Results for commit 32883fc. ± Comparison against base commit b0d3736.

@rbuergi
rbuergi merged commit 5623efc into main Aug 10, 2026
18 checks passed
@rbuergi
rbuergi deleted the fix/dynamic-nodetype-wire-typing branch August 19, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants