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 bind failure is refused only when
JsonException.Pathnames a MEMBER ($.country), never when it blames the document as a whole ($). A missingrequiredmember is the ordinary partial-content shape a legitimate writer produces; refusing it is a different decision, and not this one. 🚨 "Not refused" is not the same as "not caught" — see below. - Unmapped members are refused only in the TOTAL case — at least one member present and not one
of them declared. Content carrying an extra member alongside real ones is what an older or newer
writer of the same record produces all the time, and the read path's
WarnIfLossyalready reports what it drops. Measured over this repository's 544 seeded node-content objects: exactly one shape carries no$typeat all (Systemorph/Marketing/Post), and every one of its members is declared.
🚨 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:
- 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 byContentTypeRegistrationSweep; a compiled one registers at its first instance activation. - Typed (in-process) CLR content. It already bound. See the blind spot below.
- Content whose own
$typenames a different record than the declared one — the sameDiscriminatorAdmitsshort-name rule the recovery path applies. Judging such content by the declared type would be reshaping it; the discriminator guard owns that case. - 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.
Related
- Update Validators See Typed Content — what the update pipeline owes a validator, and the retype case where it must not.
- Content-Type Registration — how a NodeType's content type becomes known, which is the precondition for judging anything here.
- CQRS and Content Access — why
ContentAs<T>and never a cast, and what a silent null looks like from outside.