Skip to content

Write guard false-positive: module.content_write_corruption reverts correct writes when block attrs contain <, > or & #10

Description

@MaikdW

Summary

Since the new write guard, diviops_module_update refuses and reverts correct writes whenever the serialized block contains characters that WordPress escapes in block-attribute JSON.

serialize_block_attributes() encodes <, >, &, quotes — and -- sequences (so they can't terminate the --> block comment) — as \u00xx. Each escape makes the stored bytes longer than the bytes the plugin sent. The guard appears to compare its own pre-escape expectation against the post-escape readback, concludes the content is corrupt, and rolls back a write that WordPress performed correctly.

The clearest single piece of evidence is case (C) below: WordPress stored exactly the intended change, and the plugin threw it away.

Environment

diviops-agent (WP plugin) 1.5.41-beta — regression appeared with this update; 1.5.4 wrote the same content fine
@diviops/mcp-server 1.5.35
Divi 5.9.0
WordPress / PHP 7.0 / 8.4

Evidence

(A) freeForm CSS with child combinators — isolating the cause

Writing css.desktop.value.freeForm on a divi/section. The CSS contained 17 > characters:

{"ok":false,"error":{"code":"module.content_write_corruption",
 "message":"Refused page #1901 module update content write because WordPress readback did not match the requested content.",
 "hint":"The original content was restored. Inspect the corruption diagnostics before retrying this full-content write.",
 "data":{"target":{"post_id":1901,"label":"page #1901 module update"},
  "bytes":{"expected":134859,"stored":134944,"previous":135153},
  "issues":[{"type":"byte_mismatch","first_mismatch_offset":2206,
             "expected_bytes":134859,"stored_bytes":134944}],
  "revert":{"attempted":true,"verified":true,"error":null}}}}

Delta = 85 = 17 × 5 — exactly the overhead of >>.

Same CSS, all > removed from selectors, but one -> left inside a CSS comment:

"bytes":{"expected":135220,"stored":135225,"previous":135153},
"issues":[{"type":"byte_mismatch","first_mismatch_offset":3099,
           "expected_bytes":135220,"stored_bytes":135225}]

Delta = 5 = 1 × 5. Still refused.

Removing that last > as well → write succeeded. This isolates the cause unambiguously.

(B) Same failure on a Theme Builder header layout

Writing freeForm CSS containing #menu-main>li>a and many .et-l--header (the -- gets escaped too):

{"ok":false,"error":{"code":"module.content_write_corruption",
 "message":"Refused page #1003 module update content write because WordPress readback did not match the requested content.",
 "data":{"target":{"post_id":1003,"label":"page #1003 module update"},
  "bytes":{"expected":6753,"stored":6853,"previous":7051},
  "issues":[{"type":"byte_mismatch","first_mismatch_offset":6213,
             "expected_bytes":6753,"stored_bytes":6853}],
  "revert":{"attempted":true,"verified":true,"error":null}}}}

Delta = 100.

(C) The serious one — a correct write, thrown away

Removing a font family ("Mulish", 6 characters) from a divi/text module whose innerContent is
<p style="font-family:Mulish;…">…</p>:

{"ok":false,"error":{"code":"module.content_write_corruption",
 "message":"Refused page #1901 module update content write because WordPress readback did not match the requested content.",
 "data":{"target":{"post_id":1901,"label":"page #1901 module update"},
  "bytes":{"expected":135215,"stored":135243,"previous":135249},
  "issues":[{"type":"byte_mismatch","first_mismatch_offset":10240,
             "expected_bytes":135215,"stored_bytes":135243}],
  "revert":{"attempted":true,"verified":true,"error":null}}}}

Look at stored vs previous: 135249 − 135243 = 6 — exactly the 6 characters of "Mulish" that were meant to disappear. WordPress stored precisely the intended result.

The plugin, however, expected 135215 (28 bytes fewer) and reverted it. Those 28 bytes are the escape overhead of that module's own HTML:
2 × < and 2 × > (<p></p>) at +5 each = 20, plus 2 × " (around the style value) at +4 each = 8. 20 + 8 = 28.

So the guard's expectation is computed on unescaped content, while the readback is escaped.

Impact

  • No divi/text module can be updated any more — virtually all of them carry markup in innerContent.
  • No custom CSS containing a child combinator > can be written. Workaround: descendant selectors plus :not(.wrapper .x .x). It is easy to trip over again — a single -> in a comment is enough.
  • Theme Builder layouts are affected identically (case B).

To be fair: the rollback itself is reliable (revert.attempted: true, revert.verified: true) — nothing gets corrupted, no data is lost. The change simply never lands, while the error message suggests corruption. But it silently blocks a large part of the write API.

Steps to reproduce

  1. Take any divi/text module whose content contains HTML (e.g. <p style="…">…</p>).
  2. Call diviops_module_update with any trivial attribute change.
  3. module.content_write_corruption; the change is reverted although WordPress stored it correctly.

Equivalently: write any css.…freeForm value containing a >.

Suggested fix

Don't compare raw bytes across the escaping boundary. Either

  • apply the same encoding WordPress uses (serialize_block_attributes()JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS, plus the -- handling) to the expected content before comparing, or
  • compare the parsed blocks / attribute arrays instead of the serialized string.

As it stands, the guard reports corruption in exactly the case where WordPress did its job correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions