Skip to content

fix(markdown): a MarkdownContent payload with no body binds as an empty document - #7097

Merged
rbuergi merged 5 commits into
mainfrom
fix/markdown-content-absent-binds-empty
Oct 24, 2026
Merged

rbuergi merged 5 commits into
mainfrom
fix/markdown-content-absent-binds-empty

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Refs #5736, Refs #6639. Both issues come from the same place: MarkdownContent.Content was required.

Root cause

A payload that names MarkdownContent and has no content member threw JsonException on every bind. That included payloads carrying only members the record itself declares, such as {"$type":"MarkdownContent"} or {"$type":"MarkdownContent","tags":[…]}, which are just documents with no body yet. Three things followed:

  • Read: ObjectPolymorphicConverter degraded the node to raw JSON ("could not be deserialized; preserving raw JSON"). ReadMarkdownContent then reported an ordinary empty page as Unreadable (because of tags).
  • Update: NodeUpdatePipeline logged As<MarkdownContent> for <path> could not recover value: JsonException at Error and passed the validators an untyped snapshot.

Fix

Proof

New arms, run with the fix removed (negative control): 7 failed in MeshWeaver.Hosting.Test and 2 failed in MeshWeaver.Graph.Test. With the fix, all pass: Hosting NodeUpdateContentTypeChangeTest + MarkdownContentWithoutBodyTest 37/37, Graph MarkdownUnreadableContentTest + ContentSchemaValidationTest 24/24.

  • Update pipeline (WithExistingContentTyped, real hub options, JsonElement and DOM): a body-less payload is typed as an empty MarkdownContent, keeps its tags and abstract, and logs nothing. Counterparty: $type plus body/markdown/text stays raw and stays at Error, and the log names the path but not the text.
  • Read seams (MeshNodeStreamCache GetStream and GetQuery): the body-less payload arrives typed with no warning. Through the hub serializer it reads as Absent and writes back with content, so the row heals on its next save. A body-keyed payload stays a JsonElement and reads as Unreadable.

Release -warnaserror is clean for MeshWeaver.Markdown, .Graph, .Documentation, Hosting.Test and Graph.Test.

Why Refs and not Fixes

The production rows measured on #5736/#6639 (ReinsuranceDemo/Cedents and ReinsuranceDemo/Claims, plus the repaired Posts/*) carry their body under an undeclared key. Those rows are refused by design, and they close through the installer fix (MeshWeaver.Reinsurance#262) and the data repair already described on the issues. This PR removes the other kind, the body-less one, which was failing for no reason. Not established: whether any live row has this body-less shape. No query isolated one.

Pairs-with: none — required removed from one member (a relaxation: existing callers that set it keep compiling and binding); no public type or member leaves.

🤖 Generated with Claude Code

…ty document

MarkdownContent.Content was `required`, so a payload naming MarkdownContent
with no `content` member threw on every bind, even when it carried nothing
but members the record declares ({"$type":"MarkdownContent"},
{"tags":[...]}). The read seam degraded such a node to raw JSON, the
overview called an ordinary empty page Unreadable, and every update logged
"As<MarkdownContent> for <path> could not recover value" at Error.

Content is no longer required. IJsonOnDeserialized binds an absent body as
"" UNLESS the payload carries a member the record does not declare
(body/markdown/text): the body may be under that key, so it is still
refused (#4600) and keeps its Unreadable read and its Error on update.
ReadMarkdownContent counts only undeclared members as unreadable.

Refs #5736, #6639

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:06

Copilot AI left a comment

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.

🟡 Changes recommended

Malformed metadata can be reported as empty, and HTML-only documents can lose their rendered output after normalization.

2 open issues
What changed in this PR

This PR allows body-less MarkdownContent payloads to bind as empty documents while preserving rejection of payloads whose body may be stored under an undeclared key.

Changes:

  • Replaces the required body property with conditional deserialization validation.
  • Updates empty-versus-unreadable classification and adds read/update regression tests.
  • Documents accepted and refused payload shapes.
File Description
test/​MeshWeaver.Hosting.Test/​NodeUpdateContentTypeChangeTest.cs Tests empty-body binding and rejection of alternate body keys during updates.
test/​MeshWeaver.Hosting.Test/​MarkdownContentWithoutBodyTest.cs Tests typed empty documents through both read paths.
test/​MeshWeaver.Graph.Test/​MarkdownUnreadableContentTest.cs Tests serialization, classification, and body-field write-back.
src/​MeshWeaver.Markdown/​MarkdownContent.cs Adds empty-body normalization and undeclared-member validation.
src/​MeshWeaver.Graph/​MarkdownOverviewLayoutArea.cs Excludes declared metadata members from unreadable classification.
src/​MeshWeaver.Documentation/​Data/​Architecture/​UpdateValidatorsSeeTypedContent.md Aligns update-validation documentation with the revised contract.
src/​MeshWeaver.Documentation/​Data/​Architecture/​ContentSchemaOnWrite.md Documents which body-less payloads bind or fail.

Review effort: Balanced

Comment thread src/MeshWeaver.Markdown/MarkdownContent.cs Outdated
Comment thread src/MeshWeaver.Graph/MarkdownOverviewLayoutArea.cs Outdated
…ared member stays unreadable

Review on #7097.

- MarkdownContent refuses a payload with no `content` but a stored
  `prerenderedHtml`: its source is missing, so MarkdownBody.Render must fall
  back to the stored HTML (GetPreRenderedHtml kept returning the document
  before this PR). An explicit `"content": ""` still binds and renders empty.
- ReadMarkdownContent asks MarkdownContent's own contract whether a body-less
  raw payload binds, instead of filtering by member NAME, so a declared member
  holding a wrong-shaped value (`"tags": "# text"`, an object under
  `abstract`) stays Unreadable and is named.

Refs #5736, #6639

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:20

Copilot AI left a comment

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.

🟡 Changes recommended

Remaining classification and source-normalization paths can still hide existing document text or stored HTML.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity Preserve null source so stored HTML fallback remains available

src/​MeshWeaver.Markdown/​MarkdownContent.cs:45

Normalizing JSON null here bypasses the new stored-HTML guard. For {"$type":"MarkdownContent","content":null,"prerenderedHtml":"<p>Existing document</p>"}, MarkdownBody.Render previously found no string source and returned the stored HTML. This setter now stores "", so OnDeserialized returns early and rendering produces an empty document instead. Keep null in the backing field until the callback checks for stored HTML; the public getter can remain non-null. Add this null-source regression alongside the missing-source and explicit-empty-string cases.

Medium severity Preserve node-level cached HTML when source is missing

src/​MeshWeaver.Markdown/​MarkdownContent.cs:162

The new guard protects HTML inside the content payload, but not the separate MeshNode.PreRenderedHtml cache. A node with a nonempty node-level cache and {"$type":"MarkdownContent","tags":["draft"]} now binds with an empty body because this guard sees no HTML. Previously the payload stayed raw, so MarkdownBody.Render reached its node-level fallback. Now the non-null empty getter makes Render return an empty document before checking that cache (MarkdownBody.cs:86–90). Preserve whether the source was supplied across binding and rendering so both cache locations remain usable when source is missing, without reviving stale HTML after an explicit empty-string clear. Add a serialization-to-render regression with node-level cached HTML.

2 resolved since last review

Review effort: Balanced

Comment thread src/MeshWeaver.Graph/MarkdownOverviewLayoutArea.cs Outdated
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    17 files   -    10      17 suites   - 10   55m 1s ⏱️ - 12m 8s
12 726 tests  - 1 582  12 726 ✅  - 1 385  0 💤  - 197  0 ❌ ±0 
12 756 runs   - 1 582  12 756 ✅  - 1 385  0 💤  - 197  0 ❌ ±0 

Results for commit 5d52308. ± Comparison against base commit 23a1840.

This pull request removes 1645 and adds 37 tests. Note that renamed tests count towards both.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---)
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
…
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "c6c7af33-5f73-4993-8dae-44a8f5cf3f72")
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.AggregateException: One or more errors occurred. (Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.)
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.InvalidOperationException: landing faulted
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.SessionDenialIsAnAnswerTest ‑ OnlyAVerdictReadsAsADenial(shape: "the same verdict nested, as a late denial dispatch"···, failure: System.InvalidOperationException: write failed
 ---> System.UnauthorizedAccessException: Access denied
   --- End of inner exception stack trace ---, isDenial: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's FIRST member", fault: System.AggregateException: One or more errors occurred. (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's SECOND member", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall nested in an aggregate inside a wrapper", fault: System.InvalidOperationException: outer
 ---> System.AggregateException: One or more errors occurred. (Value does not fall within the expected range.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.ArgumentException: Value does not fall within the expected range.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---

   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall wrapped as InnerException", fault: System.InvalidOperationException: outer
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "an aggregate of unrelated faults", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Value does not fall within the expected range.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
, expected: False)
MeshWeaver.Graph.Test.MarkdownUnreadableContentTest ‑ BodylessContentWithNodeLevelHtml_RendersTheStoredHtml_WhileAnExplicitClearStaysEmpty
…

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits October 24, 2026 08:09
Review on #7097. ReadMarkdownContent's contract check binds
case-insensitively, so raw {"Content":"# doc"} - which the exact-case
`content` lookups miss - binds WITH the document and was then reported
Absent, putting the invitation to edit over it. Read the bound record's
body instead: non-empty is Present, empty is Absent.

Refs #5736, #6639

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 24, 2026 06:11

Copilot AI left a comment

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.

🟡 Changes recommended

Missing-body normalization still hides stored documents when their HTML resides on MeshNode.PreRenderedHtml.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread src/MeshWeaver.Markdown/MarkdownContent.cs Outdated
…bind, render and write-back

Review on #7097. A body-less MarkdownContent was normalised to Content == "",
so on a node carrying its rendering on MeshNode.PreRenderedHtml,
MarkdownBody.Render rendered the empty source and never reached the
node-level fallback, hiding the stored document.

MarkdownContent now keeps "no source" as its own state (HasSource false):
- the wire member `content` is omitted while there is no source, so a
  write-back never turns it into a deliberate clear;
- MarkdownBody.Render falls back to the cached HTML for it;
- ReadMarkdownContent shows it Unreadable over a cached rendering (typed
  and raw), never the invitation to edit.
An explicit "content": "" is still a clear: it renders empty.

Refs #5736, #6639

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 24, 2026 06:45

Copilot AI left a comment

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.

🟢 Approval recommended

No unresolved blocking issues remain, and the regression coverage addresses the prior defects in binding, rendering, and source preservation.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@rbuergi
rbuergi merged commit 0d1f1e7 into main Oct 24, 2026
53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants