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
The control instance, 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, when that record EXISTS — 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. A$typethat resolves to no type at all is not this case and IS refused — see "A$typethat names nothing" below. - 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.
The MCP patch and update verbs close it for the keys the caller NAMED
The blind spot bit an agent on 2026-09-29, on the control instance: a patch of a Crm/Counterparty
node carrying aliases, domains and matchCaseSensitive — members the NodeType had gained minutes
earlier, while the portal still had the type compiled BEFORE them bound — answered
Patched: <path> twice (once after a recycle), with the node still at version 7 and none of the keys
on read-back. The typed deserialisation dropped the three keys, and the landed-write check
(ProjectTouched) read a key the serializer does not write as "expect it absent", which the live node
satisfied on its first emission.
The verbs hold the one thing the validator never sees — the caller's RAW keys — so they judge there
(MeshOperations.UnknownContentMembers):
- A top-level content key the bound type does not declare is REFUSED, naming every such key, the
type and the NodeType, and nothing is written — not even the declared keys beside it. The message
names the remedy for the stale-type case: recompile the NodeType and recycle it, then retry. It
judges only typed content: untyped content (
JsonElement) keeps every key, a type with[JsonExtensionData]keeps unknown keys by design,$-prefixed wire metadata is never a member, and nested objects are not descended into. - A
patchthat changes nothing answersNo change: <path> (vN), neverPatched:— the stored node and the node the merge would write serialise identically, so nothing is written and the version stays.Patched:is therefore always a write that landed, with its version delta.
This is narrower than the write boundary on purpose, and consistent with the "extra member alongside
real ones" rule above: that rule protects a WRITER round-tripping a whole record it did not author;
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:
ContentDiscriminatorValidatorexempts 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
$typenamed "a different record"; - the MCP
createverb's schema probe looked the content type up by the NodeType PATH as a$typename (typeRegistry.TryGetType(nodeType)), which hits only when the record is named like the NodeType (Story→Story) —WithContentTyperegisters the CLR name, never the path — so forFeedback/Feedbackthe 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 — 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.