Content Is Validated Against Its Declared Shape On Write

A write whose MeshNode.Content cannot bind to the content type its NodeType declares is refused at the write boundary, naming the member and the type that member was declared as. Storing it and letting the failure surface at read time converts a caller's mistake into durable corruption that nobody can attribute.

What was measured

memex.systemorph.com, 2026-09-17 (#4601). A patch that put a JSON object into a member declared public string?:

{"content": {"country": {"$type": "CountryReference", "id": "CH"},
             "industry": {"$type": "IndustryReference", "id": "MedicalTechnology"}}}

returned Patched: … (v2 → v3) and read back at v3 with both objects stored verbatim. Neither CountryReference nor IndustryReference exists in any model — both names were invented for the probe. The record then no longer deserialised as its declared type, so every reader's ContentAs<T> answered null.

The same silence accepted a Markdown create whose text sat under a member named markdown, which MarkdownContent does not declare. That node rendered as "No content yet. Use the menu to start editing." over a full document from v1 — the read-side half is #4600.

Why the write boundary, and not tolerance at the reader

The read seams are already as tolerant as they can be, and that is the right posture there: ContentAs<T> recovers a degraded JsonElement, and IMeshContentTypeRegistry.TryRecoverForNodeType resolves a content type the reading hub never registered (Content-Type Registration). Neither can invent data that was never stored in a shape anything can read.

Tolerance is the wrong posture for a NEW write. The caller who sent the payload is the one who can fix it, in one retry, right now. Discovering it months later means finding the producer from a page that renders empty — which is what the read-side placeholder made impossible to even notice.

The two shapes that are refused

ContentSchemaValidator (src/MeshWeaver.Graph/Security/) runs in the Create and Update validation chains, beside ContentDiscriminatorValidator.

Shape Why it cannot be caught any other way
A member whose value contradicts its declared type — an object into a string?, a string into an int System.Text.Json throws, but the wire converter deliberately preserves the raw JSON rather than faulting the read (a throw wedges the grain), so the payload reaches the store intact
Content none of whose members the declared type knows UnmappedMemberHandling.Skip makes this bind cleanly to an instance carrying none of the authored data — no exception exists to catch

The refusal names the member and the declared type, and is worded in the caller's language (content.schema.memberTypeMismatch, content.schema.noDeclaredMember — see Localization).

🚨 The narrow rule, and why it is narrow

Two deliberate limits keep this from becoming a schema-strictness change that breaks writers who were never the problem:

🚨 A when filter on a catch does not mean "say nothing" — it means "escape"

A guard that declines to judge a case must still CATCH it. #4648: the whole-document exemption above was written as an exception filter —

catch (JsonException ex) when (MemberOf(ex.Path) is { } member)   // ❌

— so a $-path JsonException, which is exactly what a missing required member raises, matched no catch in the method and left the validator. The write it documents as not judged failed with the serializer's own English text (JSON deserialization for type 'MeshWeaver.Markdown.MarkdownContent' was missing required properties including: 'content') reported as the reason. That is strictly worse than refusing it: no localized message, no named member, no log line from this guard, and the reader of the error has no way to tell which component decided anything. It red core's Continuous Delivery inside three hours of the guard being registered — MeshPluginTest.FullCrudWorkflow_CreateGetUpdateDelete and Update_ExistingNode_UpdatesSuccessfully, both on a Markdown node.

The catch is now unconditional and the judgement happens inside it. A member-path failure is refused; a whole-document failure falls through to the declared-member rule rather than returning Valid. That last part matters: a content type with a required member throws before UnmappedMemberHandling.Skip can apply, so returning Valid on $ would exempt every such type from the rule that catches the {"markdown": "…"} shape — including MarkdownContent, the type the original defect was measured on.

The general form, and the reason it is worth a section: a validator's "I have no opinion" and a validator's "this write is bad" are two different answers, and an escaping exception is neither. Whenever a guard has a documented case it declines to judge, the test for that case asserts the write LANDS — an assertion that the caller sees no error is the only one that can tell "said nothing" from "threw".

What the exemption costs, stated plainly. Content that omits a required member is stored, and a reader's ContentAs<T> cannot materialise it — the same silent-empty outcome #4600 describes, reached by a different road. That is a deliberate trade, not an oversight: refusing it would make every partial write to a type with a required member fail, across every writer in the fleet, which is the schema-strictness change this guard was explicitly scoped away from. Narrowing it later is a decision about which writers may send partial content — not a bug fix — and it needs the producer side enumerated first.

Where it deliberately says nothing

Each of these answers Valid, and each is a decision rather than an omission:

  1. No entry for the NodeType in IMeshContentTypeRegistry. A type nothing declares a content type for legitimately stores free-form JSON, and an in-mesh type whose first instance has not activated yet must not have its writes refused for a fact this process has not learned. Static 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.
  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.

The blind spot this does NOT close

A payload whose $type resolves on the WRITING hub binds at the wire, and its unmapped members are dropped there — before any validator sees the node. ObjectPolymorphicConverter.Read deserialises to the resolved type, UnmappedMemberHandling.Skip discards what that type does not declare, and the validator is then handed a well-formed CLR instance of an impoverished record.

In practice the shapes that matter reach the boundary as raw JSON — an in-mesh compiled content type is never on the writing hub's $type registry, and a member-level mismatch makes the converter preserve the raw JSON on purpose — which is why both measured cases are covered. The residue is a payload that both resolves and carries extra members, on a hub that knows the type. Closing it means judging the RAW bytes at the API boundary, before deserialisation, and that is a separate change with its own cost.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.