Repository navigation
fix(mesh): ContentAs<T> recovers same-named Content from a foreign dynamically-compiled assembly — cures the empty deployed dashboards - #218
Conversation
…namically-compiled assembly — cures the empty deployed dashboards
ROOT CAUSE of agentic-pensions#12 ("virtual-data-source workspaces stay empty
after the 2026-07-02 deploy") — proven live on atioz with an in-grain
diagnostic NodeType, and NOT the suspected stream-message drop: the 200-item
Query<MeshNode> arrives completely in the grain workspace, but every row's
Content is typed as the OWNING NodeType assembly's CLR type
(BalanceSheetEntry @ DynamicNode_AgenticPension_BalanceSheetEntry).
Chain: (1) serialize-time auto-registration (PolymorphicTypeInfoResolver.
ComputeDerivedTypes -> GetOrAddType) teaches the ROOT registry the short-name
$type -> owning assembly's type on the first root-level serialize of such
content; (2) the query layer (MeshQuery.Options = root hub options) then
materializes every result row's Content as that type; (3) consumer NodeType
hubs compile their OWN copy of the shared record (@@-included sources) - same
short name, different assembly - so ContentAs<T> hit the default: branch (not
T, not JsonElement) and silently returned null. Every loader that filters on
content fields dropped all rows -> permanently empty EntryNode/RevEntry ->
"Noch keine Bilanzwerte geladen", while metadata-only collections populated
(the false "size-dependent snapshot drop" signature). Order/restart-dependent
(registry learns on first write), which is why A/B revert experiments
misattributed it to the framework build diff.
Fix: the default: branch now recovers by a JSON round-trip
(SerializeToElement(content, content.GetType(), options) -> Deserialize<T>),
symmetric with the degraded-JsonElement recovery — cross-assembly identity
mismatch is the NORMAL shape for shared query results in a mesh of
dynamically compiled NodeTypes; the JSON shape is authoritative and the read
site owns typing (the documented ContentAs contract). Unrecoverable shapes
still return null, now logging the assembly-qualified ActualType.
Repro red->green: ContentAsForeignAssemblyContentTest (3 facts) reproduces
the full pollute -> materialize -> consume chain through real hub options.
Hosting.Test 56/56, ThreadViewModelJsonElementTest green, solution Release
-warnaserror clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a production issue where MeshNodeContentExtensions.ContentAs<T>() returned null for MeshNode.Content materialized as a same-named CLR type from a different dynamically-compiled NodeType assembly, causing content-filtered collections (dashboards) to remain empty after deployment.
Changes:
- Extend
ContentAs<T>()to attempt recovery of non-T, non-JsonElementtyped content via a JSON round-trip. - Add a regression test that reproduces the root-hub materialization + consumer-hub
ContentAs<T>()mismatch scenario.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/ContentAsForeignAssemblyContentTest.cs | Adds a regression test covering cross-assembly same-name typing recovery (and related shape-binding behavior). |
| src/MeshWeaver.Mesh.Contract/MeshNodeContentExtensions.cs | Implements JSON round-trip recovery for foreign-typed Content in ContentAs<T>(), with additional logging. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…'s TypeRegistry — kill the registry poisoning that armed agentic-pensions#12 Completes the cure with the ARMING root-fix (reconciled from a parallel investigation that reached the same RCA independently): 1. PolymorphicTypeInfoResolver never auto-registers a type from a COLLECTIBLE assembly (dynamic node / kernel-script compilations — every recompile is a NEW assembly with a NEW CLR identity for the "same" class). Adopting one into a long-lived hub's registry as a serialization side effect poisoned the hub for the rest of the process: every later $type resolution (e.g. mesh-query row deserialization) yielded THAT foreign/stale CLR type, and consumers holding their own compilation read Content as null. It also pinned the collectible assembly, defeating unload. The discriminator is still formatted and written; reads on non-owning hubs degrade to JsonElement, which ContentAs<T> recovers at the consumer. 2. ContentAs<T> recovery hardened per Copilot review: serialize AS the concrete runtime type (the object-declared path routes through ObjectPolymorphicConverter.GetTypeName's unguarded GetOrAddType and would defeat the resolver guard), recovery logged at Debug (per-row on a healthy path — Warning would storm at snapshot size), unrecoverable-shape error now carries the ASSEMBLY identity (dynamic types have no namespace — the bare name is identical on both sides of a cross-assembly mismatch). 3. RouteStreamMessage's absent-sync-hub drop now logs one Warning with StreamId/message type/hub (the silent Ignored() made data-sync drops unfindable in production — issue #12's explicit ask). Tests: TypeRegistryTest collectible-assembly facts (real emitted collectible assembly), MeshNodeContentAsTest (contract facts), ContentAsForeignAssembly- ContentTest (real-hub-options pollute→materialize→consume chain), VirtualDataSourceContentTypeTest (full loader corridor). Suites green: Messaging.Hub 81, Hosting 61, Data 274, Query 343; solution Release -warnaserror clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results (shard 1)1 305 tests - 97 1 304 ✅ - 97 5m 40s ⏱️ - 1m 7s Results for commit f843470. ± Comparison against base commit 85dbdeb. This pull request removes 99 and adds 2 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 2) 15 files ± 0 15 suites ±0 7m 29s ⏱️ -29s Results for commit f843470. ± Comparison against base commit 85dbdeb. This pull request removes 193 tests.♻️ This comment has been updated with latest results. |
Test Results 54 files ± 0 54 suites ±0 19m 6s ⏱️ -57s Results for commit f843470. ± Comparison against base commit 85dbdeb. This pull request removes 292 and adds 10 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
…-assembly-recovery
…es — preserve probe-dispatch The first cut recovered ANY typed-but-not-T content by shape, which defeated probe-dispatch call sites: the token validator's `ContentAs<ApiTokenIndex>() ?? treat the node as the token itself` got a half-populated ApiTokenIndex out of an ApiToken (both carry TokenHash), followed the null TokenPath, and every API-token login failed "Token not found" — the 7 McpAccessControlTests failures on shard 0. Cross-assembly identity means the SAME type name with a different CLR identity (the same class compiled into two dynamic node assemblies). A differently-named registered type is a legitimate "this is another domain type" answer and must stay null; by-shape binding of differently-named content remains the degraded- JsonElement path's job (untyped content only). The DifferentlyNamedTargetType fact now pins the strict-null contract instead of the by-shape binding it used to assert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #217 shipped Data/Skill/access.md without updating the two catalog expectation lists. The miss never turned CI red because MeshWeaver.AI.Test hits the 6-minute wall-clock cap on CI (exit=124) and a timed-out project's trx never reaches the consolidated results — the failures are masked, the shard stays green. The timeout-masking gap and the other two real AI.Test failures it hides (McpNegativeOperationsTest.DeletedNode_EveryVerb_FailsCleanly, CodeCellOutputCaptureTest.Progress_Of_Succeeded_Activity_Renders_Output_For_ Anonymous_VUser) are separate defects and stay open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CI shard 0 caught a real regression in the first cut of the recovery: all 7 Fixed in 2787d1e: recovery is now gated on short-name equality — exactly the cross-assembly identity case it exists for. Differently-named typed content stays null (the probe contract); by-shape binding of differently-named content remains the degraded-JsonElement path's job. The f843470 additionally catches the two skill-catalog expectation lists up with the 🤖 Generated with Claude Code |
Summary
Fixes Systemorph/agentic-pensions#12 — on the deployed portal, every virtual-data-source collection whose loader filters on content fields stayed empty forever (
AgenticPension/DashboardEntryNode = 0 of 200,/AgenticPension/ExtraktionsReviewstuck on "Noch keine Bilanzwerte geladen"), while metadata-only collections populated.Root cause (NOT the suspected stream drop)
The issue's working theory (large snapshot silently dropped by the RouteStreamMessage
Ignored()+ idle-release race) is falsified: a diagnostic NodeType run in-grain on client-i showed the 200-itemQuery<MeshNode>result arriving completely — butContent.GetType()on every row wasBalanceSheetEntry @ DynamicNode_AgenticPension_BalanceSheetEntry, andContentAs<BalanceSheetEntry>returned null for every row, silently.The chain:
PolymorphicTypeInfoResolver.ComputeDerivedTypes→GetOrAddType) teaches the root registry a short-name$type→ the owning NodeType assembly's CLR type, the first time such content is serialized at root level (persistence writes).MeshQuery.Options= root hub options) then materializes every result row's Content as that CLR type.@@-included sources) — same short name, different assembly — soContentAs<T>'sdefault:branch (notT, notJsonElement) returned null, and the loaders'.Where(Content is not null …)filtered every row.This is order/restart-dependent (the registry learns on the first root-level serialize), which is why A/B revert experiments misattributed it to the framework build diff, and why "small collections work" (their loaders never touch Content, or filter only on non-null) masqueraded as a size-dependent stream bug.
Fix (three layers — read-site recovery + the arming root-fix + observability)
PolymorphicTypeInfoResolvernever auto-registers collectible-assembly types (Assembly.IsCollectibleguard). Dynamic node / kernel-script compilations produce a NEW CLR identity per recompile; adopting one into a long-lived hub's registry as a serialization side effect is what armed the outage — and it also pins the collectible assembly, defeating unload. The$typediscriminator is still formatted and written; reads on non-owning hubs degrade toJsonElement. This kills the poisoning at its source.MeshNodeContentExtensions.ContentAs<T>: thedefault:branch now recovers any foreign materialized shape by a JSON round-trip (SerializeToElement(content, content.GetType(), options)→Deserialize<T>), symmetric with the existing degraded-JsonElementrecovery — serialized AS the concrete runtime type so it routes through the guarded resolver, notObjectPolymorphicConverter's unguardedGetOrAddType(per Copilot review). Recovery logs at Debug (healthy per-row path); unrecoverable shapes return null with an assembly-qualified error (dynamic types have no namespace — the bare name is identical on both sides of the mismatch).RouteStreamMessage's absent-sync-hub drop logs one Warning (StreamId + message type + hub) instead of a fully silentIgnored()— the issue's explicit observability ask.This PR reconciles two independent investigations that converged on the same RCA (see the coordination trail in the commits).
Tests
TypeRegistryTestcollectible-assembly facts (against a REAL emitted collectible assembly): serializing never poisons the registry.ContentAsForeignAssemblyContentTest(red→green): the full pollute → materialize → consume chain through real hub options, incl. registry-survival of the consumer's own registrations.MeshNodeContentAsTest: the plainContentAscontract facts (typed / JsonElement / foreign-typed).VirtualDataSourceContentTypeTest(Query.Test): the full virtual-data-source loader corridor.-warnaserrorclean.Notes
MeshNodeStreamCache.DeserializeContenthas the mirror gap (returns the node unchanged when Content is already foreign-typed) — benign today because the cache hub's registry only knows framework types; noted as follow-up.🤖 Generated with Claude Code