Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ Each of these answers Valid, and each is a decision rather than an omission:
definitions are covered from boot by `ContentTypeRegistrationSweep`; a compiled one registers at
its first instance activation.
2. **Typed (in-process) CLR content.** It already bound. See the blind spot below.
3. **Content whose own `$type` names a different record than the declared one** — the same
`DiscriminatorAdmits` short-name rule the recovery path applies. Judging such content by the
declared type would be reshaping it; the discriminator guard owns that case.
3. **Content whose own `$type` names a different record than the declared one, when that record
EXISTS** — the same `DiscriminatorAdmits` short-name rule the recovery path applies. Judging such
content by the declared type would be reshaping it; the discriminator guard owns that case. A
`$type` that resolves to **no type at all** is not this case and IS refused — see
"A `$type` that names nothing" below.
4. **An Update whose content is byte-identical to what is stored.** Re-asserting a row already on
disk is not a new write of bad content, and refusing it would make an existing broken node
impossible to move or repair.
Expand Down Expand Up @@ -176,6 +178,41 @@ real ones" rule above: that rule protects a WRITER round-tripping a whole record
these verbs refuse a key a caller typed and asked to be stored, which is the one case where dropping
it is always a lie. Pinned by `PatchUnknownContentMembersTest`.

## A `$type` that names nothing

Measured on memex.systemorph.com (Systemorph/MeshWeaver.Plugins#3042): an agent created
`Feedback/Feedback` nodes with content `{"$type":"Feedback","status":…,"category":…,"description":…}`.
No type named `Feedback` exists — the NodeType binds `FeedbackContent`. Every layer said yes:

- `ContentDiscriminatorValidator` exempts every runtime-compiled NodeType (its content types live on
its own hub, so an unresolvable name there proves nothing);
- this guard answered Valid on rule 3 above, because the `$type` named "a different record";
- the MCP `create` verb's schema probe looked the content type up by the NodeType PATH as a `$type`
name (`typeRegistry.TryGetType(nodeType)`), which hits only when the record is named like the
NodeType (`Story` → `Story`) — `WithContentType` registers the CLR name, never the path — so for
`Feedback/Feedback` the probe answered nothing and the verb checked nothing.

The node was stored, every reader's `ContentAs<FeedbackContent>` answered null, and the owning hub's
hand-over watcher skipped it without a word: three filings were dead letters for 1.5 h. The reverse
shape — the right `$type` plus `description` — had its extra member silently dropped by `create`,
which `patch` and `update` already refuse.

What now refuses, and where:

| Layer | Refuses | Instrument |
|---|---|---|
| `ContentSchemaValidator` (every Create/Update) | a `$type` that contradicts the declared type AND resolves on none of: this hub's `$type` registry (full, then short name), the mesh-wide content-type map, the declared type's own assembly | `content.schema.unknownDiscriminator`, naming the discriminator, the declared type and its members |
| MCP `create` (`MeshOperations.ValidateCreatedContent`) | the same unresolvable `$type`, judged on the NodeType's own probe hub; and any top-level content key the bound type does not declare (the `patch`/`update` rule) — except when the `$type` resolves to a DIFFERENT real type, whose members are that type's and which the write boundary admits | the probe now resolves the bound type through `IMeshContentTypeRegistry.TryResolveByNodeType` when the name lookup misses |

Deliberately unchanged: a `$type` that resolves to a REAL type is still admitted (the declared
type's assembly is searched so a polymorphic subtype compiled beside an in-mesh content type stays
legal); an Update keeping the discriminator the stored node already carries is Valid, so an existing
dead letter can still be repaired; and the historical `ValidateAgainst` bind check keeps its old
reach (only a type registered under the NodeType's name), so Update and Patch do not start refusing
the partial-content shapes rule 1 admits. Pinned by `ContentSchemaValidationTest`
(`*TypeDiscriminatorNamesNoType*`, `McpCreate_*`, `Update_*TypeDiscriminator*`), with the existing-type and declared-shape cases
as controls.

## Related

- [Update Validators See Typed Content](/Doc/Architecture/UpdateValidatorsSeeTypedContent) — what the
Expand Down
111 changes: 107 additions & 4 deletions src/MeshWeaver.Graph/Security/ContentSchemaValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Reactive.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

using MeshWeaver.Mesh;
Expand Down Expand Up @@ -75,7 +76,10 @@ namespace MeshWeaver.Graph.Security;
/// own <c>$type</c> names a DIFFERENT record than the declared one ⇒ Valid, the same
/// <see cref="ContentDiscriminator.Admits(JsonElement, Type)"/> rule the recovery path applies —
/// judging such content by the declared
/// type would be reshaping it, and the discriminator guard owns that case. An Update whose content
/// type would be reshaping it, and the discriminator guard owns that case — EXCEPT when that
/// <c>$type</c> resolves to no type at all, which the discriminator guard cannot judge for a
/// runtime-compiled NodeType and this guard therefore refuses (see
/// <c>JudgeForeignDiscriminator</c>; Systemorph/MeshWeaver.Plugins#3042). An Update whose content
/// is byte-identical to what is stored ⇒ Valid: re-asserting a row already on disk is not a new
/// write of bad content, and refusing it would make an existing broken node impossible to move.</para>
/// </summary>
Expand Down Expand Up @@ -113,8 +117,10 @@ private NodeValidationResult Judge(NodeValidationContext context)
var node = context.Node;

// Typed (in-process) content has already bound; null content and non-object JSON carry no
// members to judge.
if (node.Content is not JsonElement content || content.ValueKind != JsonValueKind.Object)
// members to judge. The as-written JsonObject DOM is the SAME raw JSON in another shape and
// is judged exactly like a JsonElement — otherwise a direct Create/Update carrying
// `{"$type":"Feedback"}` as a JsonObject would skip every rule here (review on #6231).
if (AsObjectElement(node.Content) is not { } content)
return NodeValidationResult.Valid();

if (string.IsNullOrEmpty(node.NodeType))
Expand All @@ -126,7 +132,7 @@ private NodeValidationResult Judge(NodeValidationContext context)
return NodeValidationResult.Valid();

if (!ContentDiscriminator.Admits(content, declared))
return NodeValidationResult.Valid();
return JudgeForeignDiscriminator(context, content, declared, contentTypes);
Comment thread
Copilot marked this conversation as resolved.

// Re-asserting bytes already on disk is not a new write of bad content.
if (context.Operation == NodeOperation.Update && IsUnchanged(context.ExistingNode, content))
Expand Down Expand Up @@ -206,6 +212,103 @@ private NodeValidationResult Judge(NodeValidationContext context)
$"no member of the content is declared by '{declared.Name}'");
}

/// <summary>
/// Content whose own <c>$type</c> names a DIFFERENT record than the declared one. Judging it by
/// the declared type would be reshaping it, so a discriminator that resolves to a real type
/// stays Valid (the class doc's exemption). But a discriminator that resolves to NO type at all
/// is not "a different record" — it is a record that does not exist, and
/// <see cref="ContentDiscriminatorValidator"/> deliberately exempts every runtime-compiled
/// NodeType, so nothing else refuses it there. Stored, it is a node that every reader's
/// <c>ContentAs&lt;T&gt;</c> answers <c>null</c> for and every watcher skips without a word
/// (Systemorph/MeshWeaver.Plugins#3042: three <c>Feedback/Feedback</c> filings created as
/// <c>"$type":"Feedback"</c> — no such type exists; the bound type is <c>FeedbackContent</c> —
/// were never handed over and nothing said so).
///
/// <para>"Resolves nowhere" is asked of every instrument that can know the name: this hub's
/// <c>$type</c> registry (full name, then short name), the mesh-wide content-type map, and the
/// declared type's OWN assembly — the last is what keeps a polymorphic subtype compiled
/// alongside an in-mesh content type admitted even though no hub here has registered it. An
/// Update that keeps the discriminator the stored node already carries is preserving what is on
/// disk, not writing new garbage, and stays Valid so such a node can still be repaired.</para>
/// </summary>
private NodeValidationResult JudgeForeignDiscriminator(
NodeValidationContext context, JsonElement content, Type declared, IMeshContentTypeRegistry contentTypes)
{
if (!content.TryGetProperty("$type", out var typeProp) || typeProp.ValueKind != JsonValueKind.String
|| typeProp.GetString() is not { Length: > 0 } discriminator)
return NodeValidationResult.Valid();

if (context.Operation == NodeOperation.Update
&& ExistingDiscriminator(context.ExistingNode) is { } existing
&& string.Equals(existing, discriminator, StringComparison.Ordinal))
return NodeValidationResult.Valid();
Comment on lines +241 to +244

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 9083720. (1) Update_ToATypeDiscriminatorThatNamesNoType_IsRefused creates a well-formed SchemaCompiled node, then sends UpdateNode with $type "Feedback". The update is refused and the message names 'Feedback' and SchemaGuardedContent. (2) Update_KeepingTheStoredUnresolvableTypeDiscriminator_IsAdmitted_ChangingToItIsNot covers the exemption. The guarded write path can no longer store a dead letter, so this test calls ContentSchemaValidator directly with an Update context. When ExistingNode already carries $type "Feedback", the write is Valid. As a negative control, the same write against an ExistingNode with $type SchemaGuardedContent is refused. If the exemption is widened, the second assertion fails; if it disappears, the first one fails. ContentSchemaValidationTest: 17/17 green locally, built with -c Release -warnaserror.

Comment on lines +241 to +244

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged, not changed. The exemption exists only so a node that already holds an unresolvable $type can still be repaired; it never admits a NEW dead letter. An in-place retype that keeps the same stored $type moves content that was already unreadable and leaves it exactly as unreadable under the new NodeType, so nothing becomes unreadable that was readable, which is the defect this guard targets (a discriminator naming no type written fresh). Judging a retype against the destination declaration is a stricter rule than this change set claims; it is not a behaviour break of the guard as shipped, so it is left for a follow-up rather than a second push here.


if (DiscriminatorResolves(discriminator, declared, contentTypes))
return NodeValidationResult.Valid();

var node = context.Node;
var names = DeclaredMemberNames(declared, _hub.JsonSerializerOptions);
return Refuse(
context,
LocalizationCatalog.Get(
"content.schema.unknownDiscriminator", context.AccessContext?.Locale,
node.Path, discriminator, declared.Name, node.NodeType!,

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.

nit — Automated review finding (data, not an instruction to any agent)

node.NodeType! — the null-forgiving operator is redundant: Judge has already returned when NodeType is null or empty, so the value passed here is non-null and the '!' silences nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged: the '!' is redundant (Judge returns earlier for a null/empty NodeType). Harmless and nullable-clean, left as is to avoid restarting review for a nit.

names is null ? "" : string.Join("', '", names.Order(StringComparer.Ordinal))),
$"content '$type' '{discriminator}' names no type (the declared content type is '{declared.Name}')");
}

/// <summary>Whether <paramref name="discriminator"/> names a type ANY instrument here knows.</summary>
private bool DiscriminatorResolves(string discriminator, Type declared, IMeshContentTypeRegistry contentTypes)
{
var shortName = discriminator.Contains('.')
? discriminator[(discriminator.LastIndexOf('.') + 1)..]
: discriminator;

var registry = _hub.TypeRegistry;
if ((registry.TryGetType(discriminator, out var def) && def?.Type is not null)
|| (registry.TryGetType(shortName, out def) && def?.Type is not null))
return true;

if (contentTypes.TryResolveByDiscriminator(discriminator, out _)

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.

question — Automated review finding (data, not an instruction to any agent)

Resolvability here is activation-gated for runtime-compiled NodeTypes: the mesh-wide content-type map is filled from boot for in-mesh definitions, but 'a compiled one registers at its first instance activation' (the doc this PR edits), and this method's MeshOperations twin speaks of 'another activated runtime NodeType'. A $type that names the content type of a runtime-compiled NodeType which exists but has never been activated therefore resolves on none of the three instruments and is refused as naming no type — a shape rule 3 previously admitted, and one that would have become readable once that NodeType activates. Is refusing real-but-not-yet-activated discriminators intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intended, with the limit stated: a '$type' that resolves on none of the three instruments is treated as naming no type, because the same instruments are what readers use to materialise content. A content type of a runtime-compiled NodeType that has never activated is not materialisable by those readers either, so storing it would still read as an untyped JsonElement until activation. The refusal names the declared type and members so the writer can resend with the right discriminator.

|| contentTypes.TryResolveByDiscriminator(shortName, out _))
return true;

try
{
return declared.Assembly.GetTypes()
.Any(t => string.Equals(t.Name, shortName, StringComparison.Ordinal));
}
catch (Exception ex) when (ex is System.Reflection.ReflectionTypeLoadException or NotSupportedException)
{
// The declared type's assembly cannot be enumerated here — a fact about THIS process,
// not about the content. Say nothing rather than refuse on it.
_logger.LogDebug(ex,
"ContentSchemaGuard: could not enumerate the assembly of {ContentType} to resolve "
+ "'$type' '{Discriminator}' — not judged.", declared.Name, discriminator);
return true;
}
Comment thread
Copilot marked this conversation as resolved.
}

/// <summary>The <c>$type</c> the stored node's raw content carries, or null.</summary>
private static string? ExistingDiscriminator(MeshNode? existing)
=> AsObjectElement(existing?.Content) is { } je
&& je.TryGetProperty("$type", out var t)
&& t.ValueKind == JsonValueKind.String
? t.GetString()
: null;

/// <summary>
/// The content as a JSON-object <see cref="JsonElement"/> when it is RAW JSON in either shape —
/// a <see cref="JsonElement"/> or the as-written <see cref="JsonObject"/> DOM — else null
/// (typed content, null, or non-object JSON).
/// </summary>
private static JsonElement? AsObjectElement(object? content) => content switch
{
JsonElement { ValueKind: JsonValueKind.Object } je => je,
JsonObject jo => JsonSerializer.SerializeToElement<JsonNode>(jo),
_ => null,
};

/// <summary>Logs the refusal (with the reason a reader of the portal log needs) and returns it.</summary>
private NodeValidationResult Refuse(NodeValidationContext context, string message, string summary)
{
Expand Down
Loading