Back to Plate

Slate Yjs Structural Operation Coverage Ralplan

docs/plans/2026-05-25-slate-yjs-structural-operation-coverage-ralplan.md

53.0.830.9 KB
Original Source

Slate Yjs Structural Operation Coverage Ralplan

Date: 2026-05-25 Status: done Current pass: closure final gates complete Next pass: none

Sync note, 2026-05-28: this plan is still useful for the structural fallback bug class, but it is narrower than the current request. Use docs/plans/2026-05-28-slate-yjs-current-architecture-operation-matrix.md for the full operation/transform matrix and four collaboration scenarios.

Current Verdict

@slate/yjs is still not operation-complete. The latest split_node fix closed the Enter/offline-reconnect regression, but the adapter still falls back to full-document Yjs snapshot writes for these Slate operations:

  • merge_node
  • remove_node
  • replace_fragment
  • move_node

That fallback is unsafe for collaboration because writeSlateValueToYjsUnchecked deletes every visible root child before inserting the caller's local snapshot. It can make the local peer look correct while poisoning later Yjs merges.

The next implementation must be TDD-first. Do not patch implementation until the first red test proves one user-visible conflict.

Intent / Boundary Record

Intent: finish the Slate operation coverage holes that can still turn local structural edits into destructive Yjs root snapshots.

Desired outcome: every normal Slate editing operation generated by current Slate v2 user paths either has an operation-level Yjs encoder or is explicitly classified as snapshot-only with a non-user-edit reason.

In scope:

  • Add failing packages/slate-yjs/test/core-contract.ts cases first.
  • Add focused Playwright rows only for browser-visible structural editing paths.
  • Implement operation-level encoders for merge_node, remove_node, replace_fragment, and move_node.
  • Add a compile-time coverage guard so new Slate operation kinds cannot silently enter snapshot fallback.
  • Add a changeset in the later execution slice.

Non-goals:

  • Do not change raw Slate operation semantics.
  • Do not move Yjs/provider policy into raw Slate.
  • Do not claim exact upstream issue fixes from this slice before issue-ledger pass and browser proof.
  • Do not claim true CRDT move semantics for concurrent edits inside a moved subtree unless a stable-identity model is added and tested.

Decision boundaries:

  • The implementation may add internal @slate/yjs helpers and metadata.
  • Public API remains createYjsExtension(...), state.yjs, and tx.yjs.
  • Snapshot fallback stays legal for explicit whole-value replacement with no operations. It is not legal for supported user-edit operation batches.

Live Current Source

Current operation coverage:

  • packages/slate-yjs/src/core/index.ts:1246-1324 supports insert_text, remove_text, set_node, set_selection, insert_node, replace_children, and split_node; default returns false.
  • packages/slate-yjs/src/core/index.ts:2293-2298 falls back to writeSlateValueToYjsUnchecked when the operation-level encoder returns false.
  • packages/slate-yjs/src/core/index.ts:305-324 shows the fallback deletes all current Yjs children, then inserts a new serialized value.
  • packages/slate/src/interfaces/operation.ts:36-159 defines the missing operation kinds as part of the current Operation union.
  • packages/slate/test/operations-contract.ts:249-320 and packages/slate/test/snapshot-contract.ts:2777-2885 prove current raw Slate move_node and merge_node semantics.
  • packages/slate/test/collab-bookmark-position-contract.ts:101-205 proves raw Slate remote replay already handles remove_node, merge_node, and move_node bookmark/range rebasing.

Prior @slate/yjs evidence:

  • ../slate-v2/docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md says the root cause of the latest lost-edit bug was split_node falling back to full-document snapshot writes.
  • ../slate-v2/docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md says replace/delete-style edits should hide existing Yjs containers instead of deleting them when undo or concurrent inserts may need those containers alive.

External implementation evidence:

  • ../slate-yjs/packages/core/src/applyToYjs/node/index.ts:10-17 maps every legacy Slate node operation, including remove_node, merge_node, move_node, and split_node.
  • ../slate-yjs/packages/core/src/applyToYjs/node/removeNode.ts:5-15 encodes remove as a targeted parent delete, not root replacement.
  • ../slate-yjs/packages/core/src/applyToYjs/node/mergeNode.ts:13-82 merges by appending the target delta into the previous target, then deleting the absorbed target range.
  • ../slate-yjs/packages/core/src/applyToYjs/node/moveNode.ts:12-57 encodes move as targeted delete plus insert delta.
  • ../lexical/packages/lexical-yjs/src/SyncV2.ts:9-24 records the same core shape as the current package: sibling text nodes share one XmlText, and non-text nodes map one-to-one to XmlElement.
  • ../lexical/packages/lexical-yjs/src/SyncV2.ts:849-867 deletes/inserts only the changed child window, not the whole root.
  • ../y-prosemirror/src/sync-utils.js:388-394 computes a before/after diff to avoid losing delete operations during transaction-to-Yjs conversion.

Decision Brief

Principles:

  • User-edit operations must not degrade to whole-document Yjs rewrites.
  • Slate v2 operation semantics stay the source of truth.
  • Yjs containers should stay alive when hiding/removing them protects undo or concurrent edits.
  • Tests prove convergence through public editor/Yjs behavior, not private helper calls.
  • Move semantics need honesty: Yjs has delete/insert, not native shared-type relocation.

Top drivers:

  • Offline edits must converge without silent content loss.
  • Existing raw Slate operation contracts should be reused instead of inventing adapter-specific semantics.
  • The implementation must stay internal to @slate/yjs.

Viable options:

  1. Add per-operation encoders for the missing operations.
    • Pros: matches current adapter shape, minimal public API churn, testable by Y.Doc sync.
    • Cons: move_node cannot honestly preserve moved-subtree concurrent edits without extra identity metadata.
  2. Convert all commits to structural diffs before writing Yjs.
    • Pros: closer to ProseMirror/y-prosemirror diff strategy.
    • Cons: bigger rewrite, harder to preserve Slate operation intent and user history metadata.
  3. Keep snapshot fallback and only add Playwright guards.
    • Pros: fastest.
    • Cons: repeats the exact split_node bug class. Drop.

Chosen option: option 1, with an explicit guard that unsupported operation types cannot silently snapshot.

Rejected alternative: clone root snapshots after every structural op. That is the bug, not a fix.

Consequences:

  • merge_node, remove_node, and replace_fragment should be treated as P1.
  • move_node gets a safe first-stage encoder that avoids root replacement.
  • A later stable-node-identity design is required before claiming moved-subtree concurrent edit preservation.

TDD Red Tests

Add tests one at a time. Each test must fail before implementation and pass after only the matching operation encoder is added.

Red 1: merge_node Preserves A Concurrent Edit In The Surviving Left Branch

File: packages/slate-yjs/test/core-contract.ts

Name:

ts
it('merges disconnected merge_node with a concurrent text edit in the surviving branch', async () => {})

Setup:

  • Seed three docs/editors with [paragraph('alpha'), paragraph('beta')].
  • Disconnect B/C by using separate Y docs copied from the seed.
  • B replays: { type: 'merge_node', path: [1], position: 1, properties: { type: 'paragraph' }, root: 'main' }.
  • C inserts ! at { path: [0, 0], offset: 5 }.
  • Exchange Y updates among A/B/C.

Expected:

  • All peers converge to one paragraph: alpha!beta.
  • No peer contains a duplicate stale alpha or stale two-paragraph snapshot.

Why it fails now:

  • merge_node hits default -> false, so B writes a whole local snapshot and deletes/reinserts root children.

Green target:

  • Text merge and element merge update only the affected Yjs parent.
  • The left branch remains the surviving visible branch.

Red 2: remove_node Preserves A Concurrent Edit Outside The Removed Node

File: packages/slate-yjs/test/core-contract.ts

Name:

ts
it('merges disconnected remove_node with a concurrent edit outside the removed node', async () => {})

Setup:

  • Seed [paragraph('alpha'), paragraph('beta')].
  • B replays: { type: 'remove_node', path: [1], node: paragraph('beta'), root: 'main' }.
  • C inserts ! at the end of alpha.
  • Exchange Y updates.

Expected:

  • All peers converge to [paragraph('alpha!')].
  • Removed beta stays hidden/removed.
  • C's ! survives.

Green target:

  • Element remove marks the target Yjs child hidden with the existing internal deleted attribute.
  • Text-leaf remove updates only the containing Y.XmlText leaves.

Red 3: replace_fragment Does Not Rewrite Sibling Branches

File: packages/slate-yjs/test/core-contract.ts

Name:

ts
it('merges disconnected replace_fragment with a concurrent sibling edit', async () => {})

Setup:

  • Seed [paragraph('alpha'), paragraph('beta')].
  • B replays a non-root fragment replacement at path: [0]: replace the paragraph's child text leaf with { text: 'omega' }.
  • C inserts ! at the end of second paragraph beta.
  • Exchange Y updates.

Expected:

  • All peers converge to [paragraph('omega'), paragraph('beta!')].
  • Replacement of paragraph 0 does not delete/reinsert paragraph 1.

Green target:

  • replace_fragment delegates to the same child-window machinery as replace_children, but with index: 0 and children.length under operation.path.

Red 4: move_node Avoids Root Snapshot And Preserves Concurrent Sibling Edits

File: packages/slate-yjs/test/core-contract.ts

Name:

ts
it('merges disconnected move_node with a concurrent edit in an unmoved sibling', async () => {})

Setup:

  • Seed [paragraph('alpha'), paragraph('beta'), paragraph('gamma')].
  • B replays { type: 'move_node', path: [1], newPath: [0], root: 'main' }.
  • C inserts ! at the end of gamma.
  • Exchange Y updates.

Expected:

  • All peers converge to [paragraph('beta'), paragraph('alpha'), paragraph('gamma!')].
  • No duplicated stale root snapshot appears.

Green target:

  • First-stage move_node is targeted delete plus targeted insert/clone, not root snapshot.

Non-claim:

  • This test does not claim concurrent edits inside the moved subtree survive. That needs a stable Yjs node identity design because Yjs does not expose a native shared-type move primitive.

Red 5: Operation Coverage Guard

File: packages/slate-yjs/src/core/index.ts

Shape:

ts
type SlateYjsOperationType = Operation['type']
const SLATE_YJS_OPERATION_TYPES = {
  insert_text: true,
  remove_text: true,
  set_node: true,
  set_selection: true,
  insert_node: true,
  remove_node: true,
  merge_node: true,
  move_node: true,
  split_node: true,
  replace_children: true,
  replace_fragment: true,
} satisfies Record<SlateYjsOperationType, true>

Expected:

  • Typecheck fails if Slate adds a new operation and @slate/yjs has no decision for it.

Browser Proof Rows

Add browser rows after the core red/green loop, not before:

  1. Backspace merge row:
    • B offline.
    • B places caret at start of second paragraph and Backspace merges into first paragraph.
    • A appends text in first paragraph.
    • B reconnects.
    • Assert merged paragraph includes both edits.
  2. Block remove row:
    • B offline deletes a selected second paragraph.
    • A edits first paragraph.
    • B reconnects.
    • Assert second paragraph stays removed and first paragraph edit survives.
  3. Drag/move row only if the example gets a real user move control or keyboard path. Do not create a fake product button just to test private machinery.

Run:

sh
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium --grep "merge|remove|move"

Repair Plan

Shared Internal Helpers

Add internal helpers near the existing replace_children / split_node helpers:

  • hideYjsSlateChild(parent, slateIndex) using DELETED_ATTRIBUTE.
  • removeYjsTextLeaf(sharedRoot, path).
  • replaceYjsChildRange(parent, index, oldLength, newChildren).
  • cloneYjsNodeForInsert(node) reusing the current cloneYjsChild behavior.
  • getPreviousYjsSlateSibling(sharedRoot, path).

Keep helpers internal. No public package API.

remove_node

Text path:

  • Locate the leaf from getYjsTextLeaves.
  • Remove that one Slate leaf from readYjsText(sharedText).
  • Call setYjsTextLeaves.
  • If no leaves remain, preserve an empty text leaf only when Slate's parent still needs one; otherwise hide the containing Yjs child.

Element path:

  • Locate parent and slate index.
  • Mark the visible child with DELETED_ATTRIBUTE.
  • Do not call parent.delete(...) for normal user remove. Keeping the container alive is consistent with the replace-children undo/concurrency fix.

merge_node

Text path:

  • Find current leaf and previous leaf in the same Slate parent.
  • Replace the previous leaf with previous text plus current text.
  • Keep previous leaf attributes.
  • Remove current leaf metadata.
  • Call setYjsTextLeaves.

Element path:

  • Find current element and previous element.
  • Append current visible children to previous element.
  • Hide the absorbed current element.
  • Keep previous element attributes. operation.properties describes removed node properties and should not overwrite the survivor.

replace_fragment

  • Treat as "replace all children under operation.path".
  • Use replaceYjsChildRange(parent, 0, operation.children.length, operation.newChildren).
  • Set selection through existing relative-selection handling; do not encode selection into Yjs document content.
  • Reuse hidden-container semantics from replace_children.

move_node

First-stage support:

  • No-op if path equals newPath.
  • Reject moving root or moving into itself, matching raw Slate.
  • Locate the origin child and destination parent/index using pre-removal newPath semantics from raw Slate.
  • Insert a cloned serialized Yjs child at the effective destination.
  • Hide the origin child.
  • Do not rewrite siblings or root.

Required limitation row:

  • This preserves concurrent edits outside the moved subtree.
  • It does not prove concurrent edits inside the moved subtree survive.
  • If product requirements demand true moved-subtree conflict preservation, add a second plan for stable Yjs node identity/proxy moves.

Exhaustiveness Guard

Replace the silent default: return false pattern with explicit operation-type coverage. Snapshot fallback may still occur when an encoder returns false for invalid paths, but not because the operation kind was forgotten.

High-Risk Pre-Mortem

Trigger: collaboration operation encoding and conflict behavior. This is high-risk because a wrong encoder can silently corrupt shared Yjs state while each local Slate editor appears correct.

Blast radius:

  • packages/slate-yjs/src/core/index.ts
  • packages/slate-yjs/test/core-contract.ts
  • playwright/integration/examples/yjs-collaboration.test.ts
  • examples/yjs-collaboration or the current Yjs example route if browser rows need real-user controls
  • downstream users relying on offline edits, undo/redo, remote cursors, and Yjs provider reconnect behavior
  • issue/PR narrative around #5771, #5533, #1770, #2288, #3741, #2881, #3551, and #3715
  • docs/examples only if the later execution slice changes the public example UI

Failure scenarios:

  1. remove_node hides a text container too broadly and drops adjacent text leaves. This would look like a successful local delete, then lose remote text after sync.
  2. merge_node incorrectly applies removed-node properties to the survivor, diverging from raw Slate.
  3. move_node is presented as true CRDT move support and later loses concurrent edits inside the moved subtree.
  4. replace_fragment uses the wrong child count and deletes siblings outside the intended replacement window.
  5. The coverage guard prevents forgotten operation kinds, but the fallback path still snapshots because an encoder returns false for an ordinary valid user operation.
  6. Browser tests only exercise product buttons and miss the real keyboard routes (Backspace, selection delete, paste/fragment replacement).

Proof plan:

  • Unit convergence for every missing operation.
  • Unit undo/history sanity where operation batches enter Yjs undo metadata.
  • Browser proof for Backspace merge and block delete.
  • Typecheck for operation exhaustiveness.
  • Build/type/lint/package gates before implementation handoff.
  • Negative/limitation proof for move_node: first-stage clone+hide support only claims preservation of concurrent edits outside the moved subtree.
  • Browser controls must drive the same code path as real user editing, not a second bespoke mutation path.
  • Example/UI proof must keep debug state visible enough to inspect Yjs connection, selection, undo/redo, and awareness state.

Expanded proof matrix:

SurfaceRequired proofBlocks closure if missing
Unit/coreOne red-green convergence test per operation: merge_node, remove_node, replace_fragment, move_nodeYes
Integration/YjsDisconnected peer updates exchange through real Y.encodeStateAsUpdate / Y.applyUpdate, not direct Slate value comparison onlyYes
Undo/historyLocal undo after reconnect does not throw, does not become stale enabled no-op, and does not remove remote-only editsYes for touched operation families
BrowserReal-user Backspace merge and block delete rows through PlaywrightYes
Browser selectionSelection/cursor rows only claim closure when the browser test reproduces the user path, not just memory syncYes for any #5771 stronger claim
Public APIcreateYjsExtension(...), state.yjs, and tx.yjs call sites stay stableYes
PerformanceNo supported user operation may call whole-root writeSlateValueToYjsUnchecked; encoders mutate bounded parent/window stateYes
Docs/exampleAdd docs/example notes only if the example UI changes; keep current-state docs, not changelog proseConditional
Issue ledgerNo promotion to Fixes #5771 without package tests plus browser collaboration proofYes

Rollback / remediation:

  • If an encoder cannot be made safe, keep the operation explicitly classified as unsupported and make the fallback throw in dev/test rather than silently snapshot a user edit.
  • If move_node needs true moved-subtree preservation, split it into a separate stable-identity plan. Do not stretch clone+hide into a false CRDT claim.
  • If browser rows expose product-button-only divergence, delete or rewrite the buttons so they dispatch real editor commands before claiming example proof.
  • If package proof passes but browser proof fails, keep issue claims at Improves / Related and hand off the browser failure as the next owner.

Verdict: keep and revise. The plan is sound only with two hard constraints: move_node remains first-stage bounded support, and no operation kind can enter snapshot fallback because it was forgotten. Anything stronger is bullshit until stable identity and browser proof exist.

Ecosystem Strategy Synthesis

SystemSourceMechanismAvoidsStealRejectSlate targetVerdict
legacy slate-yjs../slate-yjs/packages/core/src/applyToYjs/node/index.ts:10-17explicit mapper for every node opforgotten op kindsoperation-level mapper coveragelegacy editor monkey-patchinginternal v2 encoder coverage guardagree
legacy slate-yjs../slate-yjs/packages/core/src/applyToYjs/node/mergeNode.ts:13-82targeted merge deltaroot replacementtargeted parent mutationold single XmlText root assumptionv2 Y.XmlElement/Y.XmlText helper equivalentspartial
Lexical Yjs../lexical/packages/lexical-yjs/src/SyncV2.ts:9-24text siblings share XmlText, element nodes map to XmlElementper-character object churncurrent v2 document shapeLexical node class/state modelpreserve existing @slate/yjs representationagree
Lexical Yjs../lexical/packages/lexical-yjs/src/SyncV2.ts:849-867delete/insert only changed child windowwhole-root churnbounded child-window replacementapp-specific node mappinguse replace_children-style hidden rangesagree
y-prosemirror../y-prosemirror/src/sync-utils.js:388-394final before/after diff avoids losing deletesfragile step compositionprove delete/move with replayed final docsProseMirror schema fitting in raw Slateuse tests to validate final convergencepartial

Applicable Implementation Skill Review Matrix

LensApplicabilityFindingPlan delta
tddappliedOne red test per operation family; no horizontal "write all tests first" implementation.Red 1-4 are ordered vertical slices.
performance-oracleappliedOperation encoders must mutate bounded parent windows, not serialize whole documents.Add complexity note: each op targets parent/window size, not root size.
vercel-react-best-practicesskippedCore Yjs encoder work has no React render/subscription change.Browser proof still required because example behavior changes.
react-useeffectskippedNo effect/subscription API change planned.None.
shadcnskippedNo UI/component API change in this planning slice.None.

Issue Ledger Accounting

Current status: related-issue-discovery complete; full issue-ledger pass complete.

This plan touches collaboration behavior and therefore needs the related issue discovery pass before closure. Initial ledger read found the current PR reference already keeps Yjs/collaboration readiness at Improves #5771 and does not claim a fixed issue. Keep that stance until the implementation passes unit and browser proof.

Related issue discovery result:

IssueCurrent ledger statusRelation to this planDecision
#5771ImprovesCollaboration selection/anchor failures are the nearest issue-facing pressure. Current ledger already says exact provider/browser closure is unclaimed until real adapter/browser proof exists.Keep Improves; this plan may strengthen the @slate/yjs proof route but must not promote to Fixes without real browser collaboration proof.
#5533RelatedThe plan improves the first-party Yjs binding, not Yjs-free collaboration.Keep Related.
#1770RelatedOperation-composition pressure is relevant because full-document snapshot fallback destroys op intent.Keep Related; this plan is not a general operation-composition utility.
#2288Improvesreplace_fragment / child-window replacement relates to range-shaped operations.Keep existing Improves; no public range-operation API claim.
#3741Relatedmove_node collaboration metadata/payload pressure is directly relevant.Keep Related; first-stage move_node encoder does not add moved-node payloads or exact OT closure.
#2881Related / cluster-syncedsplit_node payload pressure is already partly answered by the prior split-node Yjs encoder, but this plan does not alter raw Slate op payloads.No new claim.
#3551Fixes in existing ledgerMove undo wrong-state is already fixed by slate-history proof, not by this Yjs package slice.Preserve existing fixed claim; this plan must not rewrite that claim.
#3715docs/example onlyCollaboration example/docs pressure is adjacent if browser rows add new controls.Related only if examples change; no issue claim in this planning pass.

Issue discovery evidence:

  • docs/slate-issues/gitcrawl-live-open-ledger.md lists current open rows for #5771, #5533, #1770, #3741, #2881, #3715, and #3551.
  • docs/slate-issues/gitcrawl-v2-sync-ledger.md:93 keeps #5771 at Improves, explicitly withholding fixed/provider/browser closure.
  • docs/slate-issues/gitcrawl-v2-sync-ledger.md:178, :276, :279, :530, and :635 keep #5533, #1770, #2288, #3741, and #2881 in their existing collaboration/op-model classifications.
  • docs/slate-v2/ledgers/issue-coverage-matrix.md:212-213 records #5771/#5533 collaboration rows.
  • docs/slate-v2/ledgers/issue-coverage-matrix.md:97-100 records existing #2288, #1770, #3741, and #4178 operation/collaboration rows.
  • docs/slate-issues/test-candidate-map/5912-5771.md:341-355 identifies #5771 as ready with minor setup for high-QPS remote insert versus local selection.
  • docs/slate-issues/test-candidate-map/3797-3708.md keeps #3741 and #3715 as not direct red-test candidates.
  • docs/slate-issues/test-candidate-map/3313-2733.md keeps #2881 as architecture/API pressure, not a first-pass red test.

No issue coverage matrix, fork dossier, v2 sync ledger, or PR description changes in this pass because no exact issue claim changed.

Full issue-ledger pass result:

  • docs/slate-v2/references/pr-description.md:43-54 already says the Yjs/collaboration lane only improves #5771; package source, full example, package tests, and Playwright selection proof remain required before any #5771 fixed claim.
  • docs/slate-v2/ledgers/issue-coverage-matrix.md:97-100 already keeps #2288 at Improves and #1770 / #3741 at Related.
  • docs/slate-v2/ledgers/issue-coverage-matrix.md:212-213 already keeps #5771 at Improves and #5533 at Related.
  • docs/slate-issues/gitcrawl-v2-sync-ledger.md:93, :178, :276, :278, :279, :530, :576, and :635 already classify #5771, #5533, #1770, #3551, #2288, #3741, #3715, and #2881 correctly for this plan.
  • docs/slate-v2/ledgers/fork-issue-dossier.md:6484-6488 already records the same collaboration/op-model non-closure stance for #5771, #5533, #2288, #1770, and #3741.

Ledger decision: no durable ledger edit is needed in this pass. This plan adds implementation proof requirements, not a new fixed/improved issue claim. The later execution slice may update ledgers only if browser/package proof justifies a stronger claim.

Confidence Scorecard

DimensionScoreEvidence
React 19.2 runtime performance0.86No React surface change; browser proof still required.
Slate-close unopinionated DX0.90Public API unchanged; raw Slate operation semantics cited.
Plate and slate-yjs migration backbone0.88Keeps state.yjs/tx.yjs and package-owned Yjs policy.
Regression-proof testing strategy0.90Ordered red tests plus browser rows named.
Research evidence completeness0.91Live source, legacy slate-yjs, Lexical, y-prosemirror, related issue discovery, full issue-ledger accounting, and high-risk proof matrix cited.
shadcn-style composability0.86Not UI-facing; no product API added.

Total: 0.91

Why ready: all Slate Ralplan review passes are complete, the plan has a TDD-first red-test sequence, issue claims stay conservative, and high-risk collaboration limits are explicit. This is ready for a later Ralph/TDD implementation slice; it does not claim the package code is implemented.

Closure Final Gates

Final handoff status: complete.

Closure assertions:

  • Prior passes were already complete before this closure activation: current-state-read, related-issue-discovery, issue-ledger pass, and high-risk deliberate revision.
  • No scheduled Slate Ralplan pass remains pending, in_progress, revise, or blocked.
  • Intent, desired outcome, scope, non-goals, and decision boundaries are recorded.
  • Decision brief chooses per-operation encoders and rejects root snapshots.
  • TDD plan starts with one failing merge_node test and proceeds one vertical slice at a time.
  • Issue-ledger pass keeps #5771 at Improves, keeps related/non-closure rows unchanged, and makes no unsupported Fixes claim.
  • High-risk pass records blast radius, six failure scenarios, proof matrix, rollback/remediation, and the move_node limitation.
  • Verification requirements are named for the later implementation slice: @slate/yjs unit tests, Playwright browser rows, build, typecheck, lint, and changeset.
  • Slate Ralplan edit boundary held: this planning pass did not modify ../slate-v2 source, tests, examples, package, build, or config files.

Next owner:

  • Ralph/TDD implementation in ../slate-v2.
  • Start with Red 1 only: disconnected merge_node plus concurrent edit in the surviving branch.
  • Do not implement all four operation encoders in one horizontal batch.

Pass State Ledger

PassStatusEvidence addedPlan deltaOpen issuesNext owner
current-state-readcompleteCurrent adapter switch, Slate op union, prior split fix, external Yjs implementationsCreated TDD-first planMove true identity semantics unresolvedSlate Ralplan
related-issue-discoverycompleteRead live ledger, v2 sync ledger, issue coverage matrix, fork dossier, and candidate maps for #5771, #5533, #1770, #2288, #3741, #2881, #3551, and #3715Added related issue matrix and preserved no-new-claim stanceNone; issue-ledger pass closed the follow-upSlate Ralplan
issue-ledger passcompletePR description, v2 sync ledger, issue coverage matrix, and fork dossier rows confirm existing claim stance is already correctNo durable ledger edits; preserved no-new-claim stanceFuture execution may update only after package/browser proofSlate Ralplan
high-risk deliberate revisioncompleteExpanded trigger, blast radius, six failure scenarios, proof matrix, rollback/remediation, and revised verdictMade move_node first-stage limitation and no-snapshot fallback rule hard gatesMove subtree stable identity remains a separate future planSlate Ralplan
closure final gatescompleteFinal assertion list, no-pending pass check, edit-boundary check, and Ralph/TDD next ownerMarked plan ready for later implementation without claiming code is implementedNone for planning; implementation remains future workRalph/TDD implementation

Implementation Phases

  1. Red/green merge_node.
  2. Red/green remove_node.
  3. Red/green replace_fragment.
  4. Red/green move_node first-stage bounded support.
  5. Add operation coverage guard and typecheck.
  6. Add browser rows for merge/remove.
  7. Add changeset.
  8. Verify:
    • bun test ./packages/slate-yjs/test/core-contract.ts
    • PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium
    • bun --filter @slate/yjs build
    • bun --filter @slate/yjs typecheck
    • bun lint:fix

Ralph Handoff

When executing, start with Red 1 only. Do not implement all four operations in one shot. If Red 1 does not fail, the test is wrong because current source lacks merge_node encoding.

Keep move_node claims narrow unless a stable identity design is added.