Content-Type Registration
A NodeType declares the CLR shape of its content inside its own hub configuration:
config.AddMeshDataSource(source => source.WithContentType<PluginContent>())
WithContentType records the mapping in the mesh-wide IMeshContentTypeRegistry, and every read
seam consults that registry to turn a stored $type back into the CLR type. A type the registry
has never heard of is not an error: the polymorphic converter degrades it to a raw JsonElement
by design, because an unresolvable discriminator is indistinguishable from an unknown one. The
value then reads as absent, the view renders empty, and a reactive wait never completes — with no
exception and nothing to grep. See Serialization for that failure mode in
general, and CQRS and Content Access for reading content correctly.
The defect: registration used to follow the INSTANCE
The configuration lambda above runs when an instance hub of that NodeType cold-activates. That makes registration a side effect of somebody having created a node — which is a coincidence, not a guarantee.
Measured on a local portal, 2026-09-01. Not one node carried nodeType: Store/Plugin; an
installed course root is re-typed to Space, and nothing else instantiates the type. So
PluginContent was never registered, every Store cover computed no action buttons — no Get,
no Install, no Update — and the Subscribe page reported the product was not for sale. With the
Update lane gone, installed content could not be refreshed either. One missing registration
disabled the whole commerce surface, and deployments that happened to hold a live instance escaped
by luck, which is what let the gap hide.
The lesson generalises past the Store: any type whose nodes are all created by an installer, a migration or another partition can be defined, compiled and completely absent from the registry.
The cure: registration follows the DEFINITION
ContentTypeRegistrationSweep (src/MeshWeaver.Graph/ContentTypeRegistrationSweep.cs) is an
IHostedService wired beside the root-hub reply stream in both transports
(MonolithRegistryExtensions, OrleansServerRegistryExtensions). At start it walks every static
node definition that carries a HubConfiguration and, for each one the registry does not yet know,
builds a short-lived probe hub:
meshHub.GetHostedHub(
new Address($"content-type-registration/{Guid.NewGuid():N}"),
c => hubConfig(c.WithNodeTypePath(nodeTypePath))
.AsTransientNodeProbe(startDataSources: false));
Two properties make this cheap and safe, and both are load-bearing:
- Registration is a side effect of the configuration BUILD, not of anything running. Building
the data context executes
WithContentType, which is all that is needed. AsTransientNodeProbe(startDataSources: false)starts nothing — no sync streams, no control plane. The probe is disposed immediately. This is the same mechanism the schema probes already use; seeReadFromContentTypeinMeshOperations.
WithNodeTypePath stamps the path so the registration lands under the NodeType the definition
describes rather than under the probe's own address.
🚨 Compiled NodeTypes are deliberately NOT swept
The obvious extension — sweep the runtime-compiled types too, by loading each one's cached assembly and reading its configurations — was implemented, failed CI, and was removed rather than repaired. Two independent reasons, either sufficient:
- It destroys the content bake. Resolving a compiled type's configurations loads its assembly,
and
NodeAssemblyLoadContext.LoadNodeAssemblydeletes the file when the load throwsBadImageFormatException("deleting for regeneration",src/MeshWeaver.Compiler.Pipeline/CompilationCacheService.cs). Probing an adopted-but-not-yet-loadable bake therefore removes the store's bytes and forces a re-adoption on the next boot.ShippedPrebuiltBundlesTest(inMeshWeaver.PluginCatalog.Test, which lives inMeshWeaver.Plugins) caught this to the tick — an unchanged bundle was restamped where the boot contract requires it to be skipped. Note that it is NOT in this repo's solution: a core change can break it without any local build saying so. - It reintroduces per-NodeType boot cost. Opening every compiled type's bytes at start is exactly what the CI content bake removed: 43 re-adopted assemblies cost 13.5 s of a 101 s boot before that work. See NodeType Compilation.
The scope limit is also principled, not merely pragmatic: a compiled type registers the moment one of its instance hubs activates, and a compiled type with zero instances has no stored payload carrying its discriminator — so there is nothing for the registry's absence to degrade. The gap only bites built-in types, whose content is written by installers and other partitions, and those are precisely the static definitions the sweep covers.
🚨 Registration is an EVENT — a degrade must never be terminal
The sweep above answers "the type is defined but nothing instantiates it". It does not answer the other way a read can find the registry empty: the type is not registered YET.
A compiled NodeType registers its content type when its instance hub cold-activates, and that
happens only once Roslyn has produced the assembly. Loading nodes and compiling NodeTypes are
concurrent, so an instance's content can be read a few hundred milliseconds before its own type
exists. Every read seam handles that correctly for the emission in front of it — it asks the
registry, gets "unknown", and emits the content as an untyped JsonElement, which is the honest
answer at that instant.
The defect was what happened next: nothing. Register was a silent side effect — no event, no
observable — so a subscription that opened on the losing side of the race held the untyped value for
the life of the hub. The node itself never changes, so no further emission ever arrives to
re-convert it. The view renders empty, edit_content refuses the content, and every reactive wait
for the typed shape times out. Nothing about it is random; it is a race whose loser never recovers,
which is exactly why re-running a failing test "fixed" it
(#2952).
IMeshContentTypeRegistry.Registrations makes registration observable, and the read boundary
waits on it:
// MeshNodeStreamHandle.TypedContentObserver — arms only when the conversion DEGRADED
contentTypeRegistry.Registrations
.StartWith(Unit.Default) // closes the gap between the conversion and this Subscribe
.Select(_ => TryRetype(raw)) // re-ask; keep the answer only if it is now typed
.Where(n => n is not null)
.Take(1)
.Subscribe(n => observer.OnNext(n!), observer.OnError);
Four properties are load-bearing, and each of them is the reason a different wrong shape was not used:
- It is a subscription to the real EVENT, not a poll. No timer, no interval, no re-subscribe loop. A watchdog that re-checked "has the type shown up yet" would be the band-aid; the type becoming known is an actual thing that happens, so it is published.
- It arms only on the already-degraded path, and at most one wait exists per subscription. A node that types on the first try pays nothing, and the wait is disarmed by the next emission, by a terminal, and by disposal.
- It re-asks; it never force-fits. The notification carries no content. The seam re-runs its own
conversion and keeps the result only when it is genuinely typed, so a registration for an
unrelated type is a no-op and an unresolvable discriminator stays an untyped
JsonElement, exactly as before. - Notifications are delivered OFF the registering thread.
Registerruns inside aMessageHubConfigurationbuild; handing a subscriber's render to that thread would re-enter hub construction from inside itself.Registrationstherefore observes on the task pool, the same way a storage change notification arrives.
Where the wait lives, and why one place is enough
MeshNodeStreamHandle.Subscribe is the single boundary every workspace.GetMeshNodeStream(path)
read passes through — own-hub and cross-hub, server-side and Blazor — and every emission already
goes through its TypedContentObserver. Putting the wait there covers both the cache read
(MeshNodeStreamCache.GetStream, whose conversion runs upstream of the handle and hands the
handle a still-degraded JsonElement) and the owning hub's own read of its workspace copy.
Two seams are deliberately left alone:
MeshNodeTypeSource.ResolveJsonElementContentstores the degraded node in the owning hub's workspace, which is where the untyped value physically lives. It is not re-materialised there, because putting a repaired instance back into the workspace is a WRITE: it mints a version for what was only ever a read, and that is the#1432/#2008phantom-revision class. Every consumer of that copy reads it through the handle above, which now re-types on the way out.MeshNodeStreamCache.GetQueryis a query snapshot, not a node binding. Its consumers re-issue the query; a single held emission is not the shape of the defect.
🚨 The same race at the ENRICHMENT seam — the second cure
This section described a gap the late re-type deliberately did not close. The cure has since landed; the analysis is kept because it is the reason there are two fixes and not one.
Content typing and layout-area registration are two side effects of one configuration build. A
compiled NodeType's configuration is a single expression:
"configuration": "config => config.WithContentType<PandasExplorer>().AddLayout(layout => layout.AddPandasExplorerLayoutAreas().WithDefaultArea(\"Explorer\"))"
So an instance hub that did not bind the compiled configuration has neither the content type nor the areas — and re-typing the content afterwards does not add a renderer. The two symptoms travel together and have a common cause, but they need different cures, and only the first is fixed here.
The second cure belongs at NodeTypeEnrichmentHelpers.ApplyStreamResult
(src/MeshWeaver.Graph/Configuration/NodeTypeEnrichmentHelpers.cs). Its "no compile lifecycle
attached" branch —
if ((def.CompilationStatus is null || def.CompilationStatus == CompilationStatus.Unknown)
&& typeNode.HubConfiguration is null)
return Observable.Return(node); // bare node: no overlay, no WithOverlaySelfHeal
— returned the instance unwrapped by WithOverlaySelfHeal, unlike every sibling branch
(in-flight compile, missing bytes, execution refused). The instance falls to the mesh default hub
chain, and the binding is then permanent by three independent mechanisms: the re-enrichment
short-circuit at the top of EnrichWithNodeType (if (node.HubConfiguration != null) return …);
NodeTypeRebindWatcher, which recycles only on a change of the instance's own MeshNode.NodeType,
never on a compile transition, and which bails outright when the configuration is null; and the
absent self-heal wrapper, so nothing recycles the instance when the build later settles.
The premise of that branch — "a compile that will never start" — is a real state (a marker type, a
test-seeded definition). What it could not do is tell that state apart from "a compile that has not
been kicked off yet": a NodeType loaded from a repo or from JSON carries no CompilationStatus
until InstallCompileWatcher's first-build kickoff stamps Pending, and an absent status is all
these two states have in common. An instance activating in that window took this branch; one
activating a moment later took the healthy in-flight branch and got the compile-progress overlay plus
its self-heal. That is the race whose loser never recovered, and it produced the terminal
area-not-found verdict (LayoutDefinition.BuildNotFoundControl, emitted on stream completion)
where the transient AreaFrameClassifier.CompileProgressId promise was the true one — the exact
distinction AreaFrameClassifier exists to make.
The cure. The branch now asks two questions before binding the default:
if (compilationService is not null && NodeTypeDefinition.ParticipatesInCompilation(def))
return WithCompilationInProgressOverlay(node, nodeType, typeNode, meshHub, logger);
return Observable.Return(node); // unchanged for a type no compile is coming for
ParticipatesInCompilationis the predicate that already existed inline inNodeTypeLayoutAreas.AppendSweepSummary("only types that participate in compilation"): aConfigurationorHubConfigurationsource string,Sources, or a recordedCompilationStatus. It now lives onNodeTypeDefinitionand both sites read it, so the sweep summary and the enrichment decision cannot drift — a NodeType the summary counts as compiling while enrichment treats it as inert is the disagreement that pinned the instance.- The
compilationServicenull-check is the other half of the correctness argument, not belt-and-braces. A test-seeded NodeType that carries aConfigurationstring on a mesh with noIMeshNodeCompilationServicemust not be parked on a progress page for a build that will never run; there the original behaviour is right and is kept. Both directions are pinned byNodeTypeFirstCompileKickoffTest.
The instance now serves the compile-progress overlay — whose catch-all renderer answers every area,
so no area-not-found — wrapped by WithOverlaySelfHeal, which recycles it onto the real
configuration the moment the build lands.
Diagnosing a suspected miss
- Symptom: a view that should render structured content renders empty, or a
ContentAs<T>returnsnullfor a node whose stored JSON plainly holds the fields. No exception is logged. - Confirm the stored JSON carries a
$typeand that the type is declared by some NodeType'sWithContentType. - Ask where the read happens. A payload read on a hub that never registered the type is untyped by construction. The durable fix is often to move the read to the owning hub rather than to register the type more widely — see Serialization.
- Count the instances. If the answer is zero and the type is a built-in one, this page is your
defect; the sweep should have covered it, so check that the transport's registry extension calls
AddContentTypeRegistrationSweep().
Related Topics
- Serialization —
$typediscriminators and registering types on both ends - CQRS and Content Access — reading a node's content correctly
- NodeType Compilation — the bake, adoption, and why boot cost matters
- Static Node Providers — where the static definitions the sweep walks come from