Skip to content

Distributed Editing: An alternative to RTC - #12334

Draft
dmsnell wants to merge 264 commits into
WordPress:trunkfrom
dmsnell:add/distributed-editing
Draft

dmsnell wants to merge 264 commits into
WordPress:trunkfrom
dmsnell:add/distributed-editing

Conversation

@dmsnell

@dmsnell dmsnell commented Jun 26, 2026 •

Copy link
Copy Markdown
Member

Testing and early-previews. Entirely generated by Codex.

Pairs with WordPress/gutenberg#79602

Codex and others added 30 commits May 13, 2026 12:25
Codex added 20 commits May 28, 2026 21:35
Lock the working native PHP Yjs port at 1694b1f1997945f1f362453f79f5da286a9dfd37 so WordPress server-side merge work has a stable dependency. Use a relative local URL in .gitmodules for this server workspace.
Expand the WordPress DE-RTC runtime boundary for server-side sync metadata handling, retry-save merge decisions, presence, history, and capability-sensitive review behavior. Keeps server persistence authoritative while preserving guarded failure paths for unsafe or conflicting edits.
Add and update PHPUnit coverage for sync-meta recovery, post snapshots, history, presence, settings, permission gates, retry-submit, and retry-save behavior including partial-safe review and merge outcomes.
Update the WordPress editor asset package hash after the Gutenberg DE-RTC editor build changes so the default site can serve the matching working copy.
Add guarded review-item storage and REST list/detail/resolution endpoints for unsafe blocks rejected from partial-safe retry-save. Safe edits remain the persisted authority while review-required source is kept in bounded, capability-gated workflow storage with descriptor/detail separation.

Also records queued review-item descriptors in retry-save responses and extends retry-save REST coverage for author descriptors, admin-only detail, escaped source display, and idempotent rejection.

Verification: npm run test:php -- --filter Tests_DE_RTC_REST_Retry_Save
Extend the DE-RTC presence heartbeat to accept sanitized, content-free selection state and persist it in the presence table without raw text, client IDs, DOM selectors, post content, or save authority. Presence reads now expose selection presence as liveness data only; receivers still decide whether a canvas overlay can be mapped safely.

Bump the presence storage schema to v4, cover heartbeat/read contracts for v1/v2 selection payloads, and sync the Gutenberg editor asset version generated from the current build.

Verification: npm run test:php -- --filter 'Tests_DE_RTC_(REST_Presence|Presence_Storage)'; docker PHP syntax checks for src/wp-includes/de-rtc.php and the touched DE-RTC presence tests; git diff --check.
Bootstrap the risky-block review UI whenever DE-RTC is enabled and classify lower-privileged retry-save content relative to the current server body. This lets WordPress persist safe edits from an author while holding only KSES-sensitive proposed blocks for review instead of rejecting safe edits because an unchanged admin-authored unsafe block exists in the post.

The hypothesis was that Post 810 was not reaching the guarded review path because riskyBlockReview was bootstrapped false, and that safe author edits were being rejected by whole-document KSES comparison rather than change-relative KSES review. After enabling the gate and using the review-item classifier for retry-save and partial-safe candidates, the server persisted the safe italic Cheese edit, withheld the unsafe Custom HTML proposal, and exposed one review item.

npm run build was run afterward to copy the Gutenberg bundle into the WordPress tree.

Confirmed with:

- npm run test:php -- --filter Tests_DE_RTC_Settings

- DE_RTC_POST_810_PARTIAL_SAFE_ARTIFACT_DIR=.rtc/public-plan/cycle-20260531-220413-cest/post-810-after-ghost-render-fix npm run test:de-rtc-browser:post-810-partial-safe-cheese-video
Record the generated WordPress block file drift produced while starting the default DE-RTC test environment. The files now guard Gutenberg-specific shared navigation helpers with function_exists checks instead of only checking IS_GUTENBERG_PLUGIN, matching the generated runtime copy used by the localhost:3880 site.

This was not a product-code hypothesis for Round 001; it is committed separately to restore git hygiene before the worker evidence cycle continues.
Add conservative server-side same-block merge paths for disjoint rich-text marks and independent table cells while keeping same-cell, structure, and unsupported same-block edits as manual conflicts. Surface content-free merge evidence for table-cell coordinates and rich-text merged blocks.

Keep KSES-sensitive review checks focused on newly introduced unsafe material so safe author edits are not rejected only because existing unsafe markup remains in the post. Sync the Gutenberg editor build metadata.

Verification:
- npm -C wordpress-develop run test:php -- --group de-rtc --filter "table_cell"
Add the native Automerge PHP port as the DE-RTC sync metadata runtime and remove the old Yjs submodule.

Rename retry-save evidence, raw post-content preservation hooks, recovery repair helpers, and test expectations from Yjs to Automerge. Keep the guarded save/review paths server-owned, including no-write review-required responses and canonical Automerge repair metadata.

Verified with php -l src/wp-includes/de-rtc.php and npm run test:php -- --group de-rtc.
Checkpoint current Automerge migration work before investigating the same-paragraph non-conflicting save failure. Includes retry-save revision handling, sync-meta/recovery test updates, and navigation package metadata updates that were already present in the working tree.
Add block-native RichText content operation evidence for Automerge retry-save and merge two disjoint text splices in one paragraph. The merge remains conservative: overlapping ranges and mark/splice collisions still require manual conflict handling.

Hypothesis tested: non-overlapping paragraph word edits need to be expressed and validated as RichText text-splice operations instead of rejecting same-block text changes wholesale. Verified with focused retry-save PHPUnit coverage and the two-tab browser proof.
Treat direct database or plugin-side post_content replacement as authoritative external input when the current body no longer matches the latest valid Automerge sync meta. The snapshot path now applies the guarded recovery candidate, imports the current body as a server-owned Automerge update, and reloads the persisted post instead of returning a synthetic repaired snapshot.

Recovery apply now persists the validated candidate exactly, with scoped sync-meta KSES allowance, so the repair is not reinterpreted as a new editor save. Automerge retry-save also preserves an accepted-base revision before every write so later stale editors can still merge against the base they opened.

Verified with full DE-RTC PHPUnit coverage and post 963, where the repaired sync meta advanced from version 10 to 11 after an HTML-only direct database replacement.
Derive a public attribution key from the opaque presence session hash, carry the session key through guarded retry-save, and record authorship evidence in Automerge sync metadata without exposing raw session keys.

Treat harmless KSES serialization normalization, such as compact separator hr markup, as safe for lesser-privileged author saves while retaining review-required handling for actual unsafe HTML changes.

Verified with php -l on modified PHP files, git diff --check, targeted KSES/retry-save phpunit subset (4 tests, 77 assertions), and broader DE-RTC KSES/retry-save filter (17 tests, 382 assertions).
Refresh the WordPress-develop side of the integrated development site after the Gutenberg presence hover fix so localhost:3880 can serve the updated editor package version.

The source behavior change lives in Gutenberg commit b8534fc3049. Browser proof: npm run test:de-rtc-browser:presence-hover-panel.
Update the generated WordPress script-loader package version after syncing Gutenberg's DE-RTC status-surface changes into the default runtime.

Generated by npm run sync:gutenberg so localhost:3880 serves the dismissible review-required save notice behavior.
Update the generated script-loader package hash after syncing the Gutenberg build that contains the visible presence activity ring fix.

This is generated runtime metadata only; the source change is recorded in Gutenberg commit f9258bf194a.
Return the current user's visible pending review count separately from the post-wide pending review count so clients do not show stale review UI when no actionable review item is visible. Normalize review item timestamps before expiry calculation and cover author/admin visibility behavior in retry-save tests.

Includes the matching Gutenberg editor asset version update.
Move the native PHP Automerge port from a nested submodule into wp-includes/automerge-php and update the DE-RTC runtime loader to use the vendored Core path. Excludes nested upstream checkout and local VCS metadata so the runtime can be distributed with WordPress source.
@github-actions

Copy link
Copy Markdown

Hi there! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description.

Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making.

More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook.

Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook.

If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook.

The Developer Hub also documents the various coding standards that are followed:

Thank you,
The WordPress Project

Codex added 5 commits July 2, 2026 03:41
Updates the generated editor package version after syncing the Gutenberg pending-edit indicator changes into the WordPress development checkout.
Treat base-only KSES-sensitive records as deletions that do not require unfiltered_html review, because removing unsafe content does not persist unsafe markup.

Adds regression coverage for top-level deletion, nested deletion, and mixed delete-plus-added-risky-block classification.
Add balanced read-time presence culling for ephemeral DE-RTC rows, deleting expired sessions and more aggressively removing stale sessions without pending work while retaining pending previews for the longer grace window.

Surface culling evidence in the presence roster and read contract without touching post content, revisions, post locks, autosaves, or sync metadata. Update REST presence tests for delete-on-read behavior and pending-preview culling.

Verified with: php -l src/wp-includes/de-rtc.php; php -l tests/phpunit/tests/de-rtc/rest-presence.php; npm run test:php -- --filter Tests_DE_RTC_REST_Presence.
External edits and malformed/missing sync metadata are repaired by WordPress rather than by a logged-in editor session. Store those repair updates with a synthetic non-human recovery actor so history, REST evidence, and Automerge metadata can distinguish system recovery from human authorship without exposing user/session identifiers.

Also align retry-save review approval proof hashing with the production attribution key used by the guarded save candidate path, keeping provenance evidence deterministic across proof and write boundaries.

Verified with php -l, diff --check, focused de-rtc recovery/history/retry-save tests, and the full phpunit --group de-rtc suite.
Expose the current DE CRDT version on normal post REST responses and keep bounded content snapshots in sync metadata so base_version updates can resolve against the CRDT state carried in post_content.

Teach wp_update_post() to treat base_version as an explicit Distributed Editing merge trigger. The PHP path verifies or adopts submitted Automerge metadata when safe, otherwise fabricates a server-owned edit from the referenced CRDT version, attributes the edit to on_behalf_of, and uses that user's capabilities for review-sensitive checks.

Cover REST and wp_update_post() behavior with Tests_DE_RTC_REST_Retry_Save, including no-current-user PHP calls, missing sync-meta repair, trusted client metadata adoption, unsafe author rejection, and wp_error=false rejection semantics.

Tests: npm run test:php -- --filter Tests_DE_RTC_REST_Retry_Save
chriszarate added a commit to WordPress/gutenberg-sync-engines that referenced this pull request Aug 12, 2026
* Vendor automerge-php at includes/lib with lazy loader

Imported verbatim from the Gutenberg chriszarate/refreshed-de-rtc branch
(lib/compat/wordpress-7.1/distributed-editing/automerge-php, itself a
verbatim port of wordpress-develop add/distributed-editing, PR
WordPress/wordpress-develop#12334). MIT, PHP 8.2+, namespace
WordPress\DistributedEditing\Automerge, no WordPress dependency.

The library's conformance suite (php tests/run.php) referenced an
upstream/automerge submodule never committed on either source branch;
the 11 fixture files it reads (3 storage fixtures + 8 fuzz-crashers)
are now vendored under upstream/automerge/, fetched from
automerge/automerge@8d7b12f (MIT;
recorded in VENDORED_FROM_COMMIT.txt). Suite passes 680/680 locally.

includes/lib/automerge-php-loader.php mirrors y-php-loader.php: a lazy
PSR-4 shim plus gutenberg_sync_engines_automerge_php_is_supported()
(PHP >= 8.2 + mbstring) for callers to gate on. Excluded from phpcs
like y-php.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Port the DE-RTC merge core as a frozen engine slice

includes/engines/de-rtc/merge-core.php is the exact call-graph closure
(113 functions, ~6.4k lines) of the engine-facing entry points in the
Gutenberg chriszarate/refreshed-de-rtc branch's de-rtc.php (itself a
verbatim port of wordpress-develop add/distributed-editing): update
construction/normalization, the serialized-block and block-identity
three-way merges, the rich-text merge model, version snapshots,
sync-meta format/parse, canonicalization/hashing, and the automerge
runtime gate. Extracted token-exactly; the only deltas are the vendored
library path and delegating autoloading to automerge-php-loader.php
(marked DELTA in place). Excluded from phpcs and frozen like the
intent-log cross-language core.

The plugin loads it behind a function_exists guard so a Core/Gutenberg
build that ships DE-RTC itself wins.

tests/phpunit/wpDeRtcMergeCore.php drives the merge seam directly:
disjoint block edits merge, non-overlapping rich-text edits within one
paragraph merge, overlapping edits escalate (de_rtc_rebase_failed /
manual_conflict_required), concurrent differing appends escalate by
design, tampered/mismatched updates are rejected, engine-unaware saves
get server-generated updates, and version snapshots trim at the limit.
Two upstream-faithful behaviors are pinned rather than fixed: the
legacy native-automerge-php-v1 lane is dead code (NativePort::merge
accepts only document pairs, so operations-array updates are rejected
as unverifiable) and same-edge concurrent appends require review.

Full PHPUnit: 162 tests / 707 assertions green (baseline 148 + 14 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add WP_De_RTC_Engine: the DE-RTC merge core behind the engine SPI

The third engine (slug de-rtc): server-governed three-way merges of
whole-content proposals. Clients send proposal updates ({proposalId,
baseVersion, proposedContent, clientUpdate}); under the per-room ingest
lock (three-way merges are order-dependent, so ingest serializes like
intent-log, unlike yjs-server's lock-free CRDT path) the engine resolves
the named base from its version snapshots, merges with
wp_de_rtc_get_automerge_retry_save_result(), and either advances the
canonical document (disposition applied + a server-authored content row
peers replay) or settles the proposal as escalated
(manual-conflict-required, requires-unfiltered-html) or voided
(unknown-base-version, invalid-payload, tamper/malformed details).

Canonical state ({version, content, sync_meta.version_snapshots}) lives
in room meta — not in a post_content sync-meta block, which is what
made most of upstream DE-RTC's periphery (revision scan, kses allowance,
preservation filters) unnecessary here. Genesis is deterministic from
post content and ADOPTS an existing upstream sync-meta block (version
lineage continues, content stripped). Server-driven checkpoints reuse
the shared retention invariant (trim behind the previous checkpoint,
floor-clamp stale cursors).

The capability lane escalates whole proposals kses would rewrite
(upstream sequesters just the risky blocks for review — documented gap
until the review lane exists). Registered in the plugin and on the
settings screen.

PHPUnit: 174 tests / 763 assertions green (12 new engine tests: SPI
identity, genesis+lineage, apply/broadcast, stale-base three-way rebase,
conflict escalation, kses escalation, per-row voids, type fencing,
persistence across instances, checkpoint trim + floor clamp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add the de-rtc client engine: proposal wire over a local doc bridge

The client half of DE-RTC keeps Distributed Editing's core inversion:
the client NEVER merges. A local Y.Doc (shared engines/yjs schema —
same undo manager, awareness anchor, and record mapping as the yjs
engines) bridges the editor; the session codec speaks the proposal
wire:

- One coalesced in-flight proposal: local edits mark the doc dirty and
  a whole-content proposal is built only when none is pending, so a
  typing burst costs one proposal per poll cycle. The base version is
  the version last APPLIED to the doc — stale bases are exactly what
  the server's three-way merge is for.
- Canonical rows are deferred while local edits are dirty/in flight
  (applying them would overwrite edits the server has not seen); the
  newest deferred row applies when the local state settles. Escalated
  proposals are abandoned locally once canonical applies — the upstream
  review/decision UI is a listed follow-up, as is the client-side
  block-native descriptor builder (proposals send clientUpdate: null
  and the server derives operations via its engine-unaware writer
  lane).
- Server genesis bootstraps the doc (hydrate is a no-op; pre-bootstrap
  local changes buffer and replay), mirroring yjs-server.
- Collections are inert (the intent-log precedent): awareness flows,
  rows are ignored, nothing is proposed.

The doc bridge serializes/parses through the editor's own block library
(wp.blocks at runtime; resolved from the subtree in Jest/tsc, mocked in
tests). Registered as the third adapter in src/index.ts.

Jest: 347 tests green (8 new: identity, no-seed hydrate + genesis
bootstrap, pre-bootstrap buffering, proposal coalescing + settle,
remote apply + onRemoteChange + rebased base version, deferred
canonical under in-flight, own-row slot clearing, inert collections).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix de-rtc client rebase races; add the de-rtc collaboration e2e spec

Three client fixes found by driving the real editor two-up (each
verified with the spec + Playwright traces):

1. Own accepted rows advance the base version. A client's successive
   typing chunks were proposed against the same old base, so an
   author's continuation conflicted with their own just-accepted chunk
   (rich-text overlap at the same position). When our own row
   round-trips unchanged, advance the version WITHOUT touching the doc
   (newer keystrokes survive); the next chunk proposes against it.
2. Block-level incorporation of own merged rows. When the server merges
   peers' work into our proposal, adopt canonical blocks we have not
   edited since proposing, keep locally-edited blocks (reconciled by
   the next proposal), handle peer tail-insertions, and bail to
   deferral on any other structure change. Truly concurrent same-block
   edits resolve block-level last-writer-wins — the same class of
   silent register policy yjs-server carries, at coarser grain
   (documented in the engine comparison).
3. Dispositions only settle the proposal they name. A previous
   proposal's disposition arrives in the response AFTER the one whose
   rows already settled it; clearing the in-flight slot unconditionally
   let a peer's canonical row clobber unproposed local edits, which the
   next incorporation then wrongly pinned as 'local'.

The e2e spec (three scenarios: server-side genesis + bidirectional text
sync, concurrent different-block edits surviving the three-way merge,
and the save flow persisting both users' settled edits) passes
repeatedly in-suite and solo. It opens sessions through a local helper
that force-closes the second user's welcome guide when the fixture's
preference dispatch loses to late preference hydration (an open modal
aria-hides the page and fails discovery even though sync is healthy).
Deliberately absent scenarios are the v1 gaps: title sync, and the
empty-post concurrent-append case (a BY-DESIGN escalation under DE-RTC
policy).

Known pre-existing full-suite flake, verified NOT caused by these
changes: http-only/collaboration-sync-body-size can fail after any
engine-flip suite under load (control: yjs suite + body-size fails
identically without de-rtc involved); solo it is green, CI retries
absorb it.

Jest 347 green; de-rtc e2e 3/3; full e2e 31/32 (the flake above).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document the de-rtc engine and run automerge-php conformance in CI

- AGENTS.md: engines list, repo-layout entries for the frozen merge core
  and the vendored automerge-php (incl. the PORTING_STATUS.json rewrite
  side-effect and the fact that the shipping merge path never calls the
  library), new green baselines (Jest 347, PHPUnit 174/763, e2e 32/32
  with the pre-existing body-size-after-engine-flip flake called out),
  and the de-rtc known-gaps entry.
- docs/engine-comparison.md: de-rtc added to the one-sentence list, the
  parity and host-resource tables, known gaps (no benchmark profile, no
  review UI/title sync, block-level LWW on same-block concurrency, no
  client-side proof descriptors), and the bottom line.
- README.md: de-rtc in the engines list.
- CI: run the vendored automerge-php conformance suite (680 mapped
  upstream tests, plain php runner, no WordPress) alongside y-php's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix conflict syntax error

* Actually fix conflict

* Pin PHP 8.4 for the conformance suites; surface the automerge report

The automerge-php conformance step failed on CI while passing locally:
its grapheme-cluster paths split text with PCRE2's \X, and PCRE2 10.42
(bundled by PHP <= 8.3 builds, including the stock runner's 8.3.6 and
Ubuntu 24.04 packages) carries a GB11 bug where two ADJACENT emoji-ZWJ
sequences count as ONE extended grapheme cluster. PCRE2 10.44 (bundled
from PHP 8.4) counts two. Exactly 2 of the 680 mapped upstream tests
fail under the old tables — grapheme cursor tracking through an
inserted family emoji, and a UTF-16-boundary splice — reproduced in
php:8.2/8.3-cli containers (678/680) vs php:8.4-cli (680/680).

CI now pins PHP 8.4 via setup-php for the conformance suites (y-php is
7.4+-compatible and unaffected), and the automerge step keeps its JSON
report visible — a pass prints the summary line, a failure dumps the
failing entries — instead of discarding stdout, which had swallowed the
report entirely (the step 'failed silently' in 1.6 s). AGENTS.md
documents the PCRE2 >= 10.43 parity requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant