MeshWeaver serializes all mesh node content as polymorphic JSON. Rather than using a single global serializer, every MessageHub carries its own JsonSerializerOptions instance backed by a dedicated ITypeRegistry. This design means each hub serializes only the types it knows about and round-trips them correctly — without coupling unrelated hubs to each other's type systems.

Architecture Overview

The hub-per-registry model is the foundation. Two hubs running side-by-side can own completely different type sets, and both write to the persistence layer with their own discriminators.

flowchart TB subgraph Hub1["MessageHub (App)"] O1[JsonSerializerOptions] R1[ITypeRegistry] O1 --> R1 R1 --> T1[Story, Task, Person] end subgraph Hub2["MessageHub (Analytics)"] O2[JsonSerializerOptions] R2[ITypeRegistry] O2 --> R2 R2 --> T2[Report, Dashboard] end Hub1 --> P1[Persistence Layer] Hub2 --> P2[Persistence Layer]

How Serialization Works

1. Register types with the hub

Types are declared during hub configuration. Every type listed here will be given a $type discriminator on write and resolved back to its CLR type on read.

services.AddMeshWeaver(meshWeaver => meshWeaver
    .AddMesh(mesh => mesh
        .ConfigureHub(hub => hub
            .WithTypes(typeof(Story), typeof(Task), typeof(Person))
        )
    )
);

2. $type discriminators on the wire

When the serializer writes a registered type, it injects a $type property. Readers use that property to reconstruct the correct CLR type — even when the declared field is typed as object or a base class.

{
  "$type": "Story",
  "id": "story-123",
  "title": "Implement feature",
  "status": "InProgress"
}

3. Options flow through every persistence call

The hub's JsonSerializerOptions travels from the hub through the persistence stack all the way to the storage adapter. No intermediate layer substitutes its own options.

sequenceDiagram participant Hub as MessageHub participant PS as PersistenceService participant SA as StorageAdapter participant Store as Storage Hub->>PS: save(node, hub.JsonSerializerOptions) PS->>SA: Write(node, options) SA->>Store: Serialize with $type Store-->>SA: Stored SA-->>PS: Success PS-->>Hub: MeshNode

Key Interfaces

All persistence contracts accept JsonSerializerOptions explicitly — there is no global fallback. They are also reactive: every one of these returns IObservable<T>, never Task<T> (see Asynchronous Calls).

IStorageAdapter

Each backend adapter serializes and deserializes using exactly the options it is given (src/MeshWeaver.Mesh.Contract/Services/IStorageAdapter.cs):

IObservable<MeshNode?> Read(string path, JsonSerializerOptions options);
IObservable<MeshNode?> Write(MeshNode node, JsonSerializerOptions options);
IObservable<MeshNode> ReadMany(IReadOnlyCollection<string> paths, JsonSerializerOptions options);
IObservable<IReadOnlyList<MeshNode>> WriteMany(/* … */);
IObservable<string> Delete(string path);
IObservable<object> GetPartitionObjects(string nodePath, string? subPath, JsonSerializerOptions options);

There is no IMeshStorage interface and no GetNodeAsync / SaveNodeAsync / ReadAsync / WriteAsync on the adapter contract. GetNodeAsync / SaveNodeAsync exist only as legacy test-only aliases in MeshWeaver.Fixture/IStorageAdapterTestExtensions.cs, which forward to Read / Write. Application code never touches an adapter directly — see Data Access Patterns.

IMeshService

Query and autocomplete take the caller's options for type resolution during result projection. Both are reactive; the QueryAsync interface method is goneIMeshService.Query<T>'s Initial emission is the old "QueryAsync" snapshot (see MeshWeaver.Mesh.Contract/Services/MeshQueryExtensions.cs):

IObservable<IReadOnlyCollection<QueryResult>> Autocomplete(/* … */ AutocompleteMode mode = AutocompleteMode.RelevanceFirst /* … */);

Default Configuration

Every hub's options are built by SerializationExtensions.CreateSerializationConfiguration with these settings:

Setting Value Purpose
PropertyNamingPolicy CamelCase JavaScript compatibility
DefaultIgnoreCondition WhenWritingDefault Compact output
UnmappedMemberHandling Skip Tolerate unknown properties on read
ReferenceHandler null Reference handling fully disabled
ReadCommentHandling / AllowTrailingCommas Skip / true Tolerant parsing
IncludeFields true ValueTuple support
AllowOutOfOrderMetadataProperties true Accept a $type that isn't the first property (legacy persisted rows)

🚨 DefaultIgnoreCondition is WhenWritingDefault, not WhenWritingNull — and the difference bites. Under WhenWritingDefault a bool property whose value is false is omitted from the JSON entirely, so a property declared public bool Flag { get; init; } = true silently fails to round-trip a true → false change: the false is not written, and the reader re-applies the true default. When a default-valued member must survive the wire, annotate it [JsonIgnore(Condition = JsonIgnoreCondition.Never)].

WriteIndented and PropertyNameCaseInsensitive are not set on the hub's options — they keep the System.Text.Json defaults (false). Indented output is opt-in per adapter: FileSystemStorageAdapterFactory supplies a writeOptionsModifier that copies the options with WriteIndented = true for on-disk JSON.

Best Practices

Always pass the hub's options

Never create a fresh JsonSerializerOptions for persistence calls — a bare instance has no type registry and silently discards $type discriminators.

// Correct — type registry flows through, and the write is subscribed (cold observable)
adapter.Write(node, hub.JsonSerializerOptions)
    .Subscribe(_ => { }, ex => logger.LogWarning(ex, "Write failed for {Path}", node.Path));

// Incorrect — loses type information
adapter.Write(node, new JsonSerializerOptions());

Register every content type

Any type that appears as MeshNode.Content must be registered. Use WithTypes for domain types and WithContentType<T> for well-known content shapes:

hub.WithTypes(typeof(Story), typeof(Task), typeof(Comment))
   .WithContentType<AgentConfiguration>()

A missing registration does not throw on write — the discriminator is simply absent, and the object deserializes as JsonElement or object instead of the expected CLR type. Register early; diagnose by inspecting stored JSON for a missing $type.

For a NodeType, WithContentType lives in the type's own hub configuration, and the registration must not depend on an instance of that type existing — see Content-Type Registration for how the platform sweeps definitions at startup, and for the whole commerce surface that went dead when it did not.

The $type discriminator is the SHORT name — register the type on BOTH ends

The $type discriminator defaults to the short type name (StackControl, LayoutStackSkin), not the namespace-qualified full name (MeshWeaver.Layout.StackControl). A short name is only resolvable through the type registry — there is no Type.GetType("StackControl") reflection fallback the way there is for a full name. Two consequences, both non-negotiable:

  1. Every control, skin, and content type must be registered on BOTH the sending hub and the receiving hub. The sender writes "$type":"StackControl"; the receiver can only turn that back into a StackControl if its own registry maps StackControl → typeof(StackControl). A type registered on the sender but missing on the receiver comes back as an untyped JsonElement — every Content is StackControl soft-cast fails, the value "renders empty", and reactive waits time out (this is the class of bug behind the untyped-JsonElement sync-hub storms). For layout, both ends call AddLayoutTypes(), whose reflection sweep registers every IUiControl / Skin / StreamMessage — so controls and skins are covered automatically; any non-control record you nest inside control state must be added explicitly (see the WithTypes(...) list of LayoutAreaDefinition, PivotConfiguration, … in AddLayoutTypes).

  2. Never resolve a discriminator with Type.GetType (reflection) instead of the registry. Reflection only ever worked because the old discriminator was a full name; it silently masked types that were never registered on the receiver. With short names it returns null and the value is dropped. The registry is the single source of truth — resolution flows through ITypeRegistry.TryGetType (which also resolves legacy full names via an alias) or, for polymorphic members, through the hub's PolymorphicTypeInfoResolver (whose derived-type discriminators are the same registry short names). A custom JsonConverter that resolves a $type by hand (e.g. SkinListConverter) must go through the registry / resolver, never bare Type.GetType — otherwise short-named elements are silently skipped.

Backward compatibility: the registry keeps an alias from each type's full name to its definition, so JSON persisted with a legacy "$type":"MeshWeaver.Layout.StackControl" still deserializes. New writes always emit the short name.

The resolver's diagnostics must never fail the serialization they run in

PolymorphicTypeInfoResolver runs two kinds of work inside GetTypeInfo: the work the serializer needs (the $type discriminators for a base type's registered subtypes) and diagnostics — the "unregistered type serialised here" warning, and a scan of the base type's assembly for polymorphic subtypes nobody registered (which would drop to the nearest ancestor and render empty on the receiver). Because GetTypeInfo is on the path of every serialization on the hub, a diagnostic that throws does not merely lose its own warning: it fails whatever message was being written.

That was measured on the one message that must never be lost. MessageService.ReportFailure posts a DeliveryFailure back to the sender of a request that could not be delivered; serializing that envelope resolved type info for a base type whose assembly came from a module with an incomplete dependency closure (a closure without Microsoft.Agents.AI — see Module Closure Accounting). Assembly.GetTypes() on such an assembly throws ReflectionTypeLoadException for the one type it cannot load, the exception escaped the scan, and the hub logged Failed to post DeliveryFailure message for CreateNodeRequest … - breaking error cascade — the sender waited for a verdict that never came.

The rule, and how the resolver now honours it:

The closure defect itself stays a defect and is fixed where closures are built; the resolver's job is to make sure it can no longer silence the report of some other request's failure.

Use typed query helpers

IMeshService.Query<T> filters by $type and projects directly to T, avoiding manual casting — reactively, so subscribe rather than await foreach:

// Type-safe query with automatic $type filtering
meshService.Query<Story>(query)
    .Subscribe(change => { /* change.Items are already typed as Story */ },
               ex => logger.LogWarning(ex, "Query failed"));

Storage Backends

All adapters implement the same interface and accept the same options — swapping backends requires no serialization changes.

Adapter Description
FileSystemStorageAdapter Local .json files
PostgreSqlStorageAdapter PostgreSQL (the production backend)
CosmosStorageAdapter Azure Cosmos DB documents
SqliteStorageAdapter SQLite
InMemoryStorageAdapter In-memory store for testing
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.