Skip to content

Migrate DE-RTC as the third sync engine - #9

Merged
chriszarate merged 10 commits into
trunkfrom
chriszarate/migrate-de-rtc
Aug 12, 2026
Merged

chriszarate merged 10 commits into
trunkfrom
chriszarate/migrate-de-rtc

Conversation

@chriszarate

@chriszarate chriszarate commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Migrates DE-RTC (Distributed Editing, design post) from the Gutenberg chriszarate/refreshed-de-rtc branch into this plugin as the third engine, slug de-rtc, alongside intent-log and yjs-server.

Fixes #2

What "the de-rtc engine" means here

The prior port was a framework feature (~167k lines: a 28k-line de-rtc.php, 18 REST routes, presence tables, review/proof lanes, a 22k-line editor store). Reconnaissance showed the shipping merge path (native-automerge-blocks-v1) never calls Automerge — it is a hand-written block-aware three-way merge. So the engine is that merge core behind the framework SPI, and only the essentials came over:

  • includes/lib/automerge-php/ — the library vendored verbatim (MIT, PHP ≥ 8.2 + mbstring, no WP dependency), y-php-style: lazy loader, phpcs-excluded, own conformance suite in CI (680/680; its 11 upstream fixtures are now vendored — the source branches referenced a never-committed submodule). It backs only the dead legacy lane and future repair paths; tests pin that upstream-faithful behavior.
  • includes/engines/de-rtc/merge-core.php — the exact call-graph closure (113 functions, ~6.4k lines) of the engine-facing entry points, extracted token-exactly and frozen like the intent-log core. Only deltas: vendored-library path + loader delegation.
  • ~21k lines of periphery deliberately dropped — canonical state lives in sync-storage room meta instead of a wp:sync-meta block in post_content, which makes the revision-scan/kses-allowance/preservation machinery unnecessary. Genesis still adopts an embedded upstream sync-meta block, so version lineage continues.

How it works

Server (WP_De_RTC_Engine): clients send proposals ({proposalId, baseVersion, proposedContent, clientUpdate}) — the prototype's retry-save contract on the room protocol. Under a per-room GET_LOCK (three-way merges are order-dependent, unlike yjs-server's CRDT path) the engine resolves the named base from version snapshots, merges via wp_de_rtc_get_automerge_retry_save_result(), and either advances the canonical document (server-authored content rows peers replay) or settles per-proposal dispositions: escalated (manual-conflict-required, requires-unfiltered-html) or voided (unknown-base-version, tamper/malformed). Checkpoint/trim every 100 rows, deterministic genesis, engine lineage stamped.

Client (src/engines/de-rtc/): the client never merges. A local Y.Doc (shared engines/yjs/ schema, undo, awareness) bridges the editor; the codec keeps ONE coalesced in-flight proposal against the last-incorporated version, defers canonical rows while local edits are pending, advances version-only when its own row round-trips unchanged, and does block-level incorporation when the server merged peers' work in. Dispositions only settle the proposal they name (a stale disposition freeing the slot early was the subtlest bug — found via Playwright network traces).

Verification

Suite Result
Jest 347 (339 baseline + 8)
PHPUnit 174 / 763 assertions (148 baseline + 26: merge-core + engine suites)
de-rtc e2e 3/3, repeatedly, in-suite and solo
Full e2e 32/32 modulo one pre-existing flake (collaboration-sync-body-size after any engine-flip suite; control-verified with yjs alone; green solo)
automerge-php conformance 680/680 (now in CI)
lint/typecheck/build clean

Known gaps (documented in docs/engine-comparison.md)

No benchmark authoring profile yet; no review-lane UI (escalations are observable dispositions only, and the client abandons an escalated proposal once canonical applies); no title sync; clients send clientUpdate: null (the server's engine-unaware-writer lane derives operations — the client-side descriptor builder + cross-language fingerprint vectors are the next step to restore proof-carrying proposals); truly concurrent same-block edits resolve block-level last-writer-wins (yjs-server's silent-register-LWW class, coarser grain).

🤖 Generated with Claude Code

chriszarate and others added 10 commits August 12, 2026 10:24
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>
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>
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>
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>
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>
- 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>
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>
@chriszarate
chriszarate merged commit 6a65ec4 into trunk Aug 12, 2026
2 of 3 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.

Add DE-RTC engine

1 participant