Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
Name: Update Validators See Typed Content
Category: Architecture
Description: An update validator compares the existing node with the proposed one, so the pipeline owes it both sides typed alike. How a hub used to learn content types by accident, how #3056 removed that accident, and why the update pipeline now types the existing snapshot off the proposal's own CLR type.
Icon: <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
---

# Update Validators See Typed Content

**An `INodeValidator` for `Update` compares `context.ExistingNode` with `context.Node`. The
pipeline that calls it — `NodeUpdatePipeline` behind `IMeshService.UpdateNode` — owes it BOTH
sides with content of the same CLR type.** A validator that finds a `JsonElement` on one side and a
typed record on the other skips its own comparison and answers Valid, and a write that should have
been refused lands with nothing logged as an error. This page records how that happened once, and
where the guarantee now lives.

## How a hub learned content types by accident

Every hub carries a `TypeRegistry` behind its `JsonSerializerOptions`; `$type` discriminators are
resolved against it. The documented way to teach a hub a content type is `WithType(typeof(T), …)`.
There was, until 2026-09-02, a second, undocumented way: `MessageService.Post` rendered every
delivery through the logging options — eagerly, as a method argument, whether or not Debug logging
was on — and `ObjectPolymorphicConverter.Write` calls `typeRegistry.GetOrAddType(valueType)` for
every value it serialises. So a hub that merely handed a typed instance to `CreateNode` had, by the
time the request left, registered that instance's type in its own registry. Reading the node back
on the same hub then re-typed it. Nothing declared that dependency; everything that did not call
`WithType` relied on it.

Core #3056 removed the eager render (it was the allocation that threw `OutOfMemoryException` on a
production pod), and with it the accidental registration. The very next Plugins run against core
`main` failed `NodeOperationsWithUpdateValidatorTest.UpdateNode_VersionDowngrade_ShouldFail`: the
test hub never registered `UpdatableContent`, `MeshNodeStreamCache.GetStream` logged
`Content for … stayed an untyped JsonElement after deserialization`, the validator's
`ExistingNode.Content is UpdatableContent` was false, and the downgrade went through. The bisect is
exact — `804b9beca` (#3059) passes, `3d6faa284` (#3056) fails — and the warning line is the
mechanism, not a guess.

## Where the guarantee lives now

`NodeUpdatePipeline` reads the existing node through the stream cache (typed with the running hub's
options) and, before building the `NodeValidationContext`, types its content **like the proposal**:
the proposed node carries a live CLR instance of exactly the type the existing content must have
(same node, same NodeType), and the hub running the validators demonstrably holds that type. The
degraded snapshot is deserialised as `node.Content.GetType()` through the runtime-`Type` overload
of `As` (`ObjectAsExtensions.As(object, Type, options, …)`) — a concrete-type deserialisation, which
needs no registry entry for the discriminator. It is the same recovery `ContentAs<T>` performs at a
consumer, done once for every validator instead of being each one's problem.

A snapshot that will not deserialise as the proposal's type is left as it was and logged at Error by
`As`: hiding a shape mismatch from the validators would be a second silent pass.

`UpdateValidatorSeesTypedExistingContentTest` (MeshWeaver.Graph.Test) pins the contract with a
content type registered on no hub, records the CLR type the validator actually saw, and asserts the
refusal. It exists in core because the test that found the hole runs only in MeshWeaver.Plugins —
the cross-repo blind spot that let #3056 merge green.

## What this does not change

- A validator should still read payloads through `.As<T>()` / `.ContentAs<T>()` rather than a cast
(see [CQRS and content access](../CqrsAndContentAccess)); the pipeline guarantee makes the fleet's
existing validators safe, it does not make the cast idiom right.
- A test fixture that posts a custom content type should still register it with `WithType` on the
hub that reads it back. The Plugins fixture above does not, and should.
- No registry is mutated on the read path. `As` deliberately deserialises to the concrete type
without adopting it, so a same-named type from another collectible assembly cannot poison the
running hub's `$type` resolution.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
Name: A rejected update is refused again
Category: Fix
Description: An update that a validation rule should have refused — a version going backwards, for instance — could briefly go through with no error anywhere, because the rule was shown the current content in an unreadable form and stepped aside. Rules now see the current content the same way they see the proposed one.
Icon: ShieldCheckmark
Order: -20260902
---

# A rejected update is refused again

Some node types carry rules about how they may change: a version may not go backwards, a name may
not be emptied, a reference may not point at nothing. Those rules compare what a node currently
holds with what an update proposes, and refuse when the two do not fit together.

For a short window on 2 September 2026 a rule could be shown the node's current content in a raw,
unreadable form while the proposed content arrived readable. A rule that compares the two found
nothing to compare, stepped aside, and the update landed — no error, no warning where a person
would see it. The rule was fine; what it was handed was not.

Updates now always present the current content to the rules in the same readable form as the
proposed content, so a refusal is a refusal again. Nothing changes for updates the rules accept.
40 changes: 39 additions & 1 deletion src/MeshWeaver.Hosting/NodeUpdatePipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
using System.Linq;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Security;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Hosting;

Expand Down Expand Up @@ -119,6 +122,41 @@ private static IObservable<MeshNode> DoUpdate(
return candidate with { LastModified = DateTimeOffset.UtcNow };
}));

// 🚨 A VALIDATOR COMPARES THE EXISTING NODE WITH THE PROPOSED ONE, SO BOTH MUST BE TYPED
// ALIKE — and the pipeline, not the validator, owes it that (#3056 fallout, found by the
// Plugins UpdateNode_VersionDowngrade_ShouldFail test on 2026-09-02).
//
// `existing` arrives through MeshNodeStreamCache.GetStream, which re-types the cache's raw
// JSON with THIS hub's registry — and a content type this hub never registered stays a
// JsonElement there. Until #3056, every hub registered every type it ever posted as a SIDE
// EFFECT: MessageService.Post rendered each delivery through the logging options eagerly
// (the argument was built before the LogDebug call), and ObjectPolymorphicConverter.Write
// calls typeRegistry.GetOrAddType for each value it meets. A hub that had merely handed a
// typed instance to CreateNode therefore "knew" the type when it read the node back. #3056
// removed that render (it was the OOM), and with it the accidental registration — so a
// validator written as `context.ExistingNode.Content is Versioned e && context.Node.Content
// is Versioned p && p.Version < e.Version` now sees a JsonElement on the left, skips its
// own `if`, and answers Valid. A refused write went through with nothing logged as an error.
//
// The proposed node carries a LIVE CLR instance of exactly the type the existing content
// must have (same node, same NodeType), and this hub demonstrably holds that type: it is
// the type authority for this update. Deserialise the degraded snapshot AS that type, here,
// on the hub that runs the validators — the same recovery ContentAs<T> performs at a
// consumer, done once for every validator instead of being each one's problem. A snapshot
// that will not deserialise as the proposal's type is left as it was (logged loud by As):
// hiding a shape mismatch from the validators would be a second silent pass.
private static MeshNode WithContentTypedLikeTheProposal(IMessageHub hub, MeshNode node, MeshNode existing)
{
var proposed = node.Content;
if (proposed is null or JsonElement or JsonNode || existing.Content is null)
return existing;
var logger = hub.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger(typeof(NodeUpdatePipeline));
var typed = existing.Content.As(proposed.GetType(), hub.JsonSerializerOptions, logger, existing.Path);
return typed is null || ReferenceEquals(typed, existing.Content)
? existing
: existing with { Content = typed };
}

// 2. Run client-side Update validators sequentially (Concat preserves short-circuit:
// the chain stops at the first failure). Returns the mapped exception or null.
private static IObservable<Exception?> RunUpdateValidators(
Expand All @@ -136,7 +174,7 @@ private static IObservable<MeshNode> DoUpdate(
{
Operation = NodeOperation.Update,
Node = node,
ExistingNode = existing,
ExistingNode = WithContentTypedLikeTheProposal(hub, node, existing),
AccessContext = ctx,
};

Expand Down
71 changes: 71 additions & 0 deletions src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,77 @@ public static class ObjectAsExtensions
}
}

/// <summary>
/// The runtime-<see cref="Type"/> form of <see cref="As{T}"/>, for the seams that learn the
/// target type from a LIVE instance rather than from a type parameter — the update pipeline
/// handing validators an existing node typed like the proposed one is the canonical case.
/// Same contract: already an instance of <paramref name="type"/> → as-is; a degraded
/// <see cref="JsonElement"/> / <see cref="JsonNode"/> → deserialized as <paramref name="type"/>
/// (a concrete-type deserialization needs no registry entry for the <c>$type</c> discriminator);
/// a same-named foreign CLR type → recovered by a JSON round-trip; anything else → <c>null</c>,
/// logged loud. Callers keep their original value when this answers <c>null</c>.
/// </summary>
public static object? As(
this object? value,
Type type,
JsonSerializerOptions options,
ILogger? logger = null,
string? what = null)
{
ArgumentNullException.ThrowIfNull(type);
switch (value)
{
case null:
return null;
case var typed when type.IsInstanceOfType(typed):
return typed;
case JsonElement je:
try
{
return je.Deserialize(type, options);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)
{
logger?.LogError(ex, "As<{TargetType}> could not recover {What}: {RawJson}",
type.Name, what ?? "value", Excerpt(je.GetRawText()));
return null;
}
case JsonNode jn:
try
{
return jn.Deserialize(type, options);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)
{
logger?.LogError(ex, "As<{TargetType}> could not recover {What}: {RawJson}",
type.Name, what ?? "value", Excerpt(jn.ToJsonString()));
return null;
}
default:
if (value.GetType().Name == type.Name)
{
try
{
// Same reasoning as the generic overload: serialize AS the concrete runtime
// type so the read does not adopt a foreign type into the caller's registry.
var element = JsonSerializer.SerializeToElement(value, value.GetType(), options);
var recovered = element.Deserialize(type, options);
if (recovered is not null)
return recovered;
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)
{
// fall through to the loud log below
}
}
logger?.LogError(
"As<{TargetType}> for {What}: value is {ActualType} ({ActualAssembly}), not convertible",
type.Name, what ?? "value", value.GetType().FullName,
value.GetType().Assembly.GetName().Name);
return null;
}
}

/// <summary>
/// The head of a payload, for a diagnostic log line. Bounded because the value being logged is
/// by definition the one we could NOT parse — a degraded content blob or a whole snapshot can be
Expand Down
Loading
Loading