Skip to content

Single crdt cutover - #1728

Open
jorgens wants to merge 103 commits into
inkeep:mainfrom
jorgens:single-crdt-cutover
Open

jorgens wants to merge 103 commits into
inkeep:mainfrom
jorgens:single-crdt-cutover

Conversation

@jorgens

@jorgens jorgens commented Sep 18, 2026

Copy link
Copy Markdown

What and Why

Today every document is kept as two live CRDT replicas inside one Y.Doc: Y.Text('source'), the markdown that reaches disk, and Y.XmlFragment('default'), the ProseMirror tree the WYSIWYG binds. A server-side bridge keeps them equal after the fact: Observer A serializes the whole fragment and line-diffs it into Y.Text on every WYSIWYG keystroke; Observer B parses the whole Y.Text and rebuilds the fragment on every source keystroke.

This PR makes Y.Text the only CRDT. Each client parses it into a local, unsynced ProseMirror document (the projection). A WYSIWYG edit re-serializes only the top-level blocks it changed, narrows the write to the bytes that differ, and splices them into Y.Text under the user's own origin. Source mode binds the same Y.Text as before. The fragment, both observers and every guard around them are deleted. The fragment path is gone; the only code still reading the old binding is upstream's list drag (see What it costs).

  BEFORE: two CRDTs, reconciled             AFTER: one CRDT, projected
  ──────────────────────────────────        ──────────────────────────────────
  source edit  ─► Y.Text   ─► Observer B    source edit   ─┐
                             parse ─► frag  WYSIWYG edit  ─┤
  WYSIWYG edit ─► fragment ─► Observer A    agent write   ─┼─► Y.Text   (nothing
                             diff ─► Y.Text file watcher  ─┤             reconciles)
  agent / watcher / rollback ─► both        rollback      ─┘
                                            WYSIWYG = local read model of Y.Text

Note to the maintainers

This draft pull request has kept growing. The change of the data model, which it addresses, ran deeper than initially expected, and releasing the impacted pieces only makes sense, and is only safe, if it is done in one go. So unfortunately, the pull request is huge. I hope you still will take time to consider it.

What prompted the exploration was the reported issue #1475 and a not easily reproducible issue where the WYSIWYG and the source mode would differ and not agree until after a complete restart of the app.

The goal of the change:

  • One source of truth for both editors
  • One shared undo history across both editors
  • As a consequence reduction of the instrumentation built around keeping the different editors in sync

Along the way further undo-issues were uncovered and a race with your rapid release cycles started. Kudos to you for all the work you are putting into this great project!

If the PR has become too big for you to look into in more detail, this would be totally understandable, nevertheless I really believe that the architectural change would be worth it.

If for nothing else, then this draft might be useful as a proof-of-concept.

As you can see from the commits, the changes were largely done using Claude Opus, but I can assure you that the Human was in the loop, a lot of manual testing, pointing and redirection has gone into it.

Checklist

  • Ran pnpm check (lint, typecheck, tests) locally
  • Added a changeset (pnpm changeset) if this changes behavior
  • Updated docs if this changes a user-facing surface
  • I have read CONTRIBUTING.md and agree to license my contribution under the project's terms (CLA)

Why one CRDT

Two replicas that converge independently do not converge with each other. Keeping them equal is the dual-write problem, and almost every cost below comes from that one fact. A single CRDT would solve the problem.

Two CRDTs (base) One CRDT (this PR)
Convergence Reconciled by a bridge: 9 guards, 6 kill-switches, a circuit breaker Guaranteed by Yjs; nothing to reconcile
WYSIWYG state A second synced replica that can go stale or diverge A local read model, rebuilt from Y.Text, never merged
Undo Two stacks; bridge writes tracked by neither One Y.UndoManager, one LIFO across both views
Who authored an edit WYSIWYG edits reach Y.Text under the server's origin Every edit carries its author's origin
Bytes on disk Whole-document serialize-and-diff rewrites untouched blocks Only the changed block's bytes are written
Per-keystroke cost A full server-side parse per source keystroke A splice; zero server-side parses
Synced / cached bytes The fragment is 70–87% of them The fragment is gone
Remote carets Dropped across modes, both directions One coordinate space; visible in either mode

Convergence becomes a property of the data type. With one CRDT, convergence is what Yjs already guarantees for a single Y.Text. The ProseMirror document is derived from it and never synced, so there is no second state to drift, and the whole "WYSIWYG shows something other than the markdown" class of bugs cannot happen, rather than being guarded against. (Tabs still running the old client across the upgrade are the exception; see Follow-ups.)

One write path for every writer. Source mode, WYSIWYG, agent writes, the file watcher and rollback all write Y.Text, and nothing runs afterwards. Agents already only ever wrote markdown; the bridge existed to serve a single arrow, WYSIWYG → fragment → Y.Text. The client now performs that same translation itself.

One undo history. With one shared type there is one Y.UndoManager per document, and both views write through it under tracked origins. On the base, undo only retracted edits made in the view you were undoing from, and the bridge's rewrites ran under an origin neither stack tracked, so a bridge rewrite could split a user's undo frame. Undo is one LIFO: the most recent edit retracts, whichever view made it, and a source frame survives an interleaved WYSIWYG edit and retracts whole. The same attribution is what lets buffered offline edits replay under the right author.

The bytes on disk change only where you edited. A WYSIWYG edit now writes only the blocks it changed, narrowed to the bytes that differ. In a one-off run over the docs corpus plus hand-built hazards (loose lists, reference definitions, footnotes, fences, tables, blank runs), 1,752 of 1,752 block splices touched only the edited block; block-splice.test.ts keeps that containment property pinned on a large realistic fixture.

Typing is cheap at any document size. Observer B paid a full parse per source keystroke. Now neither a source nor a WYSIWYG keystroke makes the server parse or serialize markdown, and each client's own work is proportional to the change: up to 128 KiB, typing and a peer's typing both measure what they do on main. Agent writes still parse on the server, and a book-length document still stalls (see Follow-ups).

Less to sync and cache. The fragment was 2.4–6.6× the size of the Y.Text, i.e. 70–87% of every sync payload and of what IndexedDB caches. Fragments are now gone.

Carets work across modes. With one CRDT there is one coordinate space. Remote carets are anchored to Y.Text offsets and rendered in whichever mode each client is in, so a collaborator is visible across source and WYSIWYG.

A smaller system, on a well-trodden model. Fewer lines of production code and fewer lines of tests. The bridge's reach shows in the server's test surface: 43 of its test files are deleted here and 32 more rewritten.

Trade offs

  • Remote edits cost a parse on each client. The parse moved from the server, where it ran per keystroke, to each client, where it runs once per remote change — over the blocks that change touched, with a whole-document parse as the fallback whenever a window cannot be proven to parse identically.
  • Concurrent edits merge at text level. Same-paragraph co-editing is restored by narrowing the write to the bytes that changed, and by mapping the reader's caret through only the bytes a remote change touched. A tree CRDT's structural merge is no longer available.
  • Upstream's list drag still assumes the old binding. Six cases remain test.fails in list-item-drag.collab.test.ts; one flipped once a peer's edit stopped replacing the whole document. By hand the drag completes and loses nothing, so the divergence is in the collab merge model rather than in what the drag does on screen.
  • Three upstream-owned files now diverge. packages/app/src/globals.css was byte-identical to upstream; it no longer is, in three places. The leaves-the-workspace cue () hung off each fragment of a link's resolution decoration and was suppressed by an adjacent-sibling rule; this branch draws peer carets as widget decorations, and one standing inside a URL splits the run, so every fragment drew its own arrow — the cue now hangs off the link element instead. The agent-flash block-guessing wash is deleted, replaced by the position-accurate inline decoration that flashes the range an agent actually wrote. And remote-caret host blocks opt out of content-visibility, which would otherwise clip a peer's name label. No upstream TypeScript is touched, but it is ~130 lines of divergence to carry through future merges. packages/app/tests/stress/find-replace.e2e.ts follows the cue to its new carrier, and packages/app/package.json diverges in its test:e2e list: bridge-era specs out, the projection's own in.

Size of the change

Diffed against the upstream tip this branch last merged (f9a52ebcb). Tests dominate the line count, so they are split out:

Files Added Deleted Net
Production code 106 4,656 5,913 −1,257
Tests 277 10,797 28,738 −17,941
Changesets 13 65 0 +65
Docs and config 14 38 46 −8
Total 410 15,556 34,697 −19,141

Production code by package:

Package Added Deleted What
server 198 4,388 The bridge, both observers, the watchdog, pre-drain and loss-suppression machinery, the parse pool, the Observer A drain harness
app 2,793 1,363 The projection binding, shared undo manager, coordinate mapping, remote carets, the client embed resolver, the block-scoped re-projection; the fragment binding and its guards removed
core 1,649 159 Byte-accurate PM↔source map, source block spans and snapshots, the block-scoped write path (new files, editor-free); a parse context that carries the embed resolver; the four bridge switches retired from the schema into removed-key redirects
desktop 16 2 Edit menu Undo and Redo routed to the shared undo manager
cli 0 1 Parse worker removed

Most of the test deletion is the bridge-era surface: the concurrency matrix, the convergence fuzzer, and the suites and rigs that seeded or asserted the fragment. Most of the test addition is the projection's own coverage (the binding, the write path's containment oracle, cross-mode undo across a real CodeMirror and a real ProseMirror view) and the upstream tests ported onto projection-bound peers.

Why this is one PR

The short version: the cutover is a change of data model, and the pieces only make sense, and are only safe, together.

  1. The server and client halves cannot ship in different releases. The switch of every client to the projection and the removal of the bridge have to arrive together.

  2. The flag-gated intermediate state was built, and it is the worst of both. The branch went through it. In that state main carries two editor constructions and the full bridge, the server keeps parsing on every write to feed a fragment only unflagged clients read, every test has to say which arm it pins, and none of the advantages above arrive.

  3. Most of the follow-up commits fix regressions the cutover itself introduced. Moving to text-level sync broke things the tree CRDT gave for free and the docs advertise under "Real-time collaboration".

  4. The deletions cannot trail the cutover. With the bridge no longer running, its tests either fail by construction or keep passing while asserting nothing.

What has been kept out

Seven pre-existing defects surfaced along the way.

  • Issue 1 — a comment silently re-anchors onto a different passage.
  • Issue 2refind trusts its stored offsets with no context evidence.
  • Issue 3edit replaces one occurrence and reports unqualified success.
  • Issue 4 — a server restart discards the edit typed during the outage.
  • Issue 5 — an inline-authored component renders but cannot be edited.
  • Issue 7 — a template deleted on disk is written back by the save that was pending when it was deleted. This is upstream's own intermittent template-watcher-capabilities failure.
  • Issue 8 — a test's fake gh CLI outlives every run, and its orphans wreck later suites. This is the machine-state caveat under Automated suites.

Two of those attributions are not settled. Issue 1 no longer reproduces on upstream, and Issue 4's "pre-existing" reading rests on a byte-identical comparison.

The suite's reds are kept out too. See automated suites section for more information.

Check the proposed follow ups, further down.

Suggested Review Path

The branch is 95 commits (plus eight merges of upstream), and each one carries its reasoning in the message, because the no-comments policy keeps that prose out of the code.

  1. Core write path: core/markdown/pm-source-map.ts, core/markdown/source-blocks.ts, core/projection/block-splice.ts. These are pure functions over ProseMirror's document model — no editor instance, no view, no DOM — and tested against a containment oracle.
  2. The binding and undo: app/editor/projection-binding.ts, projection-coordinates.ts, shared-undo-manager.ts.
  3. The cutover (fbcbacf31), which is small: +163 / −50 lines of production code, the rest tests.
  4. The deletions (99676c280 server, 69ab4159f client, 4f6bd8e9d tests). Only the server one is mostly whole files — 46 deleted against 14 edited — and there the question for each is just "is anything still reachable". The other two are more edit than removal (client: 6 deleted, 11 edited; tests: 54 deleted, 61 edited), so they want reading rather than skimming. All three commit messages list what was deliberately not deleted, and why.
  5. The fixes after the cutover, 27 of the 30 carrying a test of their own. The two exceptions that touch product code are small: 353c8a5a4 (the loss detector handed to disk intake) and 5bcd56498 (the projection's trailing-empty floor).

How this was tested

Manual testing

Most defects the migration found came from using the app, not from the test suite. Fixes that changed behaviour ended with a manual pass. In the end the HIL is the best judge of what behaviour comes natural.

The manual checklist
# Do this Expect
MP-01 Open a 400 KB+ document containing [**Desktop**](x); type into a paragraph in the middle; check the file The edit lands. Every other block keeps its authored bytes — [**Desktop**](x) must not become **[Desktop](x)**
MP-02 One document, one session: lists (Enter, Tab/Shift-Tab, split, join), a table (rows, columns, Enter in a cell, delete), code fences, paste (multi-block, into an empty document, at a block boundary, something large), the body of a block-form JSX component, then frontmatter and body together Every edit reaches the markdown; nothing outside the edited block moves. Two known pre-existing list failures, identical on main, are not defects to file: Enter on an ordered item writes 1. not 2., and Enter on a task item writes - [ ]   . Re-checked by code identity, not by a side-by-side run: to-markdown-handlers.ts (the ordinal logic) and empty-task-item.ts (the   minting) are both zero-diff against upstream/main, as are list-editing-helpers.ts and list-item-drag.ts — the only list file this branch touches is list-item-drag.collab.test.ts. Both behaviours are decided inside that identical serializer from the node's own start and checked fields. Accepted as good enough
MP-03 Two clients on one document, one in each mode; type in both Each sees the other's caret and name label, and the labels survive a mode switch. This is the claim — cross-mode carets never worked on main in either direction
MP-04 Two clients in the same mode Each still sees the other's caret. main had this through yCursorPlugin; the cutover dropped it and it was rebuilt, so it is a no-regression row, not a feature
MP-05 Edit in the visual editor, then in Markdown source, then Cmd+Z repeatedly One LIFO: the most recent edit retracts, whichever view made it, and redo walks back up the same stack
MP-06 With edits of your own in the history, have an agent replace the whole document (or restore from the Timeline), then Cmd+Z The history is cleared in both views and nothing from before the replace comes back. This is the disclosed cost of one history, not a defect
MP-07 Click the mode toggle so focus sits on neither editor, then Cmd+Z and Shift+Cmd+Z. Desktop: repeat, then use Edit ▸ Undo and Edit ▸ Redo Both reach the app's own history, not the browser's. On main the browser's undo answers here, which can skip an edit or re-insert a deleted one. The Windows and Linux menubar path is untested in any form
MP-08 Make an edit in the visual editor, switch to Markdown source, have something else write while you are there — a peer typing, a disk change, a Properties field — then keep editing and Cmd+Z Your history survives the other write. On main, returning to source after any untracked write cleared it
MP-09 Put bridge.deferGuard, bridge.fixedPoint, bridge.preDrain and bridge.lossDetector in .ok/config.yml, start the server, then read the metrics payload Each key raises one REMOVED_KEY diagnostic naming what replaced it, and is stripped from the parsed config. The config is recovered rather than rejected: the server starts, and every other setting in the file still applies. The eight bridge counters are present and at rest — seven zeroes and an empty bridgeToleranceApplied map. ok config migrate strips the four; no other upgrade step is required
MP-10 Drive two agent writes to one file back-to-back, then two more a few seconds apart; open the Timeline The back-to-back pair may share one entry; the spaced pair gets an entry each. Every write is in the file either way
MP-11 In a 400 KB+ document, type a burst and pause; watch the word count and the lint markers No freeze on the pause. The word count catches up; lint markers appear against the blocks that changed
MP-12 Stop the server, open document B, leave its error screen up through the outage, then restart the server Zero retries fire while the server is down. On restart B retries itself and loads, at most three attempts, then holds the screen with its buttons. The stalled-sync warning names document B rather than the session
MP-13 With the server running, send an upgrade to a path it does not serve: curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" http://127.0.0.1:<PORT>/not-a-collab-path — against a real server (ok start or the desktop), not the vite dev server, whose collaboration path never installs this handler A warn line names the URL, host, origin and requested subprotocol, and the paths the collaboration host actually claims. The request is still refused exactly as before — admission is unchanged
MP-14 Two people type in the same paragraph at the same time The paragraph stays one block. Characters may interleave — that is correct at text level — but it must never duplicate. main merged this structurally; this PR merges the bytes. One machine cannot do this by hand — peer-same-line-coedit.e2e.ts is the authority. A peer's spaces appearing to vanish here is MP-15, not a merge fault: an unanchored space at the paragraph end is dropped when that peer's caret moves away, which from the other side looks like concurrent-edit loss. Interior spaces survive — measured with both peers typing space-separated words and simultaneous space runs at the paragraph end
MP-15 Type a space at the end of a line, then move the caret elsewhere; check the file The space is dropped when you move away. Changed from main, whose fragment held it. It is kept while the caret sits after it, and saved with the next word you type — trailing-space-saves-with-the-next-word
MP-16 Type [text](url), or a bare URL followed by a space, then Cmd+Z once The link and the text you typed for it go together. Changed from main, which left the literal [text](url) behind
MP-17 Drag a list item while a peer edits the same document The drag completes and nothing is lost. It is not cancelled. Upstream's list drag still anchors through the removed binding, so six app unit cases in list-item-drag.collab.test.ts stay test.fails: the divergence is in the collab merge model, not in what the drag does on screen.
MP-18 Embed a file attachment with ![[report.pdf]] It loads, and the markdown keeps the bare ![[…]] form. Its size is no longer shown — the client resolves the path but not the size the server used to add
MP-19 Open a book-length document (≈488 KiB) and have a peer type in it Expect stalls: the reader's main thread blocks 89% of the time at that size, and typing alone 39%. At 64 and 128 KiB all cases measure 0%.
MP-20 Drive an agent write into that same 488 KiB document Expect ≈1.1 s per write
MP-22 Open a document whose whole body the parser rejects (a lone </Callout>) One raw box over the whole body, editable, and repairing the tag re-derives without a reload. A document with good content around the rejected region keeps that content — same as main. A one-line <Callout>text</Callout> does not render as a component at all; only the multi-line block form does. That is identical on upstream.
MP-23 Type /he, pick Heading from the menu, then Cmd+Z past the heading The heading retracts and /he does not come back.
MP-24 In a 400 KB+ document, type a burst and pause, watching for a freeze typing quickly on main crashed the server and needed a restart; the branch stayed up. The branch is still slow at this size

Automated suites

The latest full run, one suite at a time, on an otherwise idle machine verified clear of the orphan leak below. Every row but e2e is from the sweep at 7133f19c7 (upstream f9a52ebcb); e2e is from a later run at 8776c5d0b. Nothing between those two tips touches the suites that ran earlier, and the three globals.css unit suites were re-run at the later tip and stay green.

Suite Passed / failed
typecheck, lint pass
knip 52 unused exports / 3 unused types, against upstream's 56 / 6, and none of them the branch's own
core unit 4,547 / 0, 1 skipped
server unit, including the 17 files CI skips 10,246 / 1, 6 skipped
app unit 9,350 / 0, 6 expected fail
app DOM 6,418 / 0
app integration 1,746 / 3, 2 skipped
conversion (byte stability) 105 / 0
root scripts 2,064 / 5, 1 skipped
e2e (test:e2e, 10.3 min) 817 / 2, 7 skipped
  • Read the reds against their re-runs. Every failing file was re-run alone, and every suite number above is from one full-suite pass rather than from the isolated re-run — so the table shows what a full run produces, and the bullets say what survives isolation. Running each failing spec alone is what separates a load flake from a real red.
  • Expected fails are the 6 test.fails cases for upstream's list drag, in list-item-drag.collab.test.ts. When a peer edit shifts positions mid-drag, there's no live binding to map that anchor. These are app unit cases, not e2e. They flip when the drag is fixed.
  • Server unit's 1 is upstream's own: managed-runtime-flow's "login-shell runtime performs real npm admission in its isolated cwd", which needs a registry this environment does not reach. The file is zero-diff from upstream. On a machine clear of the orphan leak below, the whole server suite runs in 6.9 minutes and produces this single red without any isolated re-run.
  • Integration's 3 are 2 + 1. The no-comments pair fails identically on upstream — test and fixture are both zero-diff, and the public mirror's fixture yields 30 diagnostics where the test wants 31. project-sweep-capacity is the documented upstream race.
  • Root scripts' five are all missing-directory failures and none is the branch's: four want packages/md-conformance and one wants plugins/ok, neither of which the public mirror ships. The branch does touch check-server-test-inputs.test.mjs, but on a different row — it retired the ../cli/tsdown.config.ts case the merge had made dead.
  • Both e2e reds are load flakes. show-ok-folders and warm-skeleton-scroll-restore fail only under full-suite contention — 2 of 2 and 3 of 3 alone — and both files are zero-diff from upstream. Two others flaked in the earlier sweep and passed alone as well, peer-same-line-coedit 3 of 3 and keystroke-cadence-danger-space 3 of 3 after one failure; both passed in the re-run.
  • Knip is a strict subset of upstream's. Every finding it reports on the branch it also reports on a clean upstream worktree, and the branch reports fewer: 52 unused exports against 56, 3 unused types against 6.
  • A machine-state caveat worth recording. An earlier session's first three server runs reported 17, 13 and 9 failing files, a different set each time, while 50 orphaned node processes sat on the machine — one left behind by every past run of local-op-auth-login-cancel-reopen.test.ts, whose fake gh fixture refuses SIGTERM by design and holds itself open with a ~12.4-day timer. With them cleared, the same 15 files went from 3,379 s and 15 reds to 208 s and 1. One such orphan was found and killed before this run. Filed as Issue 8; it is a test-fixture leak, not product code, and it predates this branch.

Follow-ups

Each to be filed as its own issue.

  • A book-length document still stalls on a peer's edit. At 488 KiB a peer typing blocks the reader's main thread 89% of the time, and typing alone 39% (208 ms median keystroke); at 64 and 128 KiB all three cases measure 0%. Parsing is no longer the cost — a window reparse is ~1% of the profile. What remains is view-side decoration work: heading-anchors rebuilds a decoration per heading on every state (23%), and ProseMirror diffs whole-document decoration sets. The fix is the one the block wrappers already use — hold the set as plugin state, map it through each transaction, rewrap only what changed. This is the first follow-up.
  • An agent write parses the whole document twice on the server. snapshotBlocks, which finds the blocks an agent write changed, used to stringify the synced tree's top-level children. It now parses the markdown, once before the write and once after, on the main thread inside the transaction. Each call takes 533 ms at 488 KiB (6,177 blocks), 726 ms at 618 KiB and 0.3 ms on a small document, so about 1.1 s per agent write at 488 KiB.
  • Upstream's list drag (5d14a3a0f) still anchors through the old binding. Six of ten ported cases diverge in the collab merge model and are marked test.fails in list-item-drag.collab.test.ts — app unit cases, not e2e. By hand the drag completes and loses nothing, so the earlier "any peer edit cancels the drag" reading is retired; what remains is the model-level divergence those six pin. They are not tests of dragging: each lands a peer edit while the drag is in flight and then asserts that both replicas converge on the same markdown. All six put that edit inside or before the dragged range — a peer typing into the item being dragged, prepending an item so every ordinal shifts, inserting a paragraph above the list — which is exactly the position absolutePositionToRelativePosition used to map the drag's anchor through. The whole-document replace was the suspected cause, and narrowing it in 1b3a86bc2 flipped three cases: the 2 of 19 SelectionAnnouncer cases, where a peer edit made the screen-reader status re-announce the selected component, and one drag case — the one whose peer edit lands outside the dragged selection, the only one that never needed the anchor remapped.
  • File attachments embedded with ![[…]] no longer show their size. This PR resolves the embed's path on the client, but not the size the server used to add. The document list already carries sizes.
  • A collaborator's caret sits in the wrong place inside a code block. Found by hand. Across modes, the peer's caret is drawn about three characters short of what they are typing in the rich-text view, and at the end of the whole block in Markdown source; outside code blocks it tracks correctly. This is a gap in a capability the cutover adds rather than a regression — upstream draws no cross-mode caret at all, so there is nothing to be worse than. By reading, caretSourceOffsetToPmPos refines a position only through spans typed text and otherwise returns the end of the span, and the span a fenced block's text child inherits includes its ``` fences; three backticks against a three-character offset is suggestive, not established. An indented code block, which has no fence, is the cheap falsification. Filed as F9.
  • Tabs left open across the upgrade still bind the old fragment, and stale fragment state persists in IndexedDB and replay buffers. Unverified by hand: reproducing it needs a pre-cutover build to start from, so the manual row for it was retired rather than recorded as a pass.
  • Naming left over from the bridge: bridge-intake.ts, bridge-quiescence.ts (now the persistence settle gate), bridge-loss-detector.ts (still live in persistence), the core/src/bridge/ utilities, the bridge.* span names, normalizeBridge, paired: true (its reader isPairedWriteOrigin is still exported but has no production caller), the Observer A and map-driven splice counters still declared in the metrics payload, and the three unused parameters applyDiskContentToDoc still declares — _resolveEmbed, _sourcePath and _resolveSize — which no call site passes.
  • An upstream race fails project-sweep-capacity intermittently. During a project-wide Fix all that churns agent sessions, the stale-external-write gate reads one of the server's own saves as an external restore of a displaced version and raises a conflict, so a concurrent agent write gets 409. Run alone, the test failed 2 of 3 times on upstream 92f35f5d9, 2 of 5 on 985247f5a, and 7 of 7 on this branch at e97b44ef5. At 4f0273995 it passed in the full integration run, so the branch does not make it deterministic after all. At d72bb13aa it failed in the full run and passed alone, at af3c4c89f the same, and again in the latest sweep: red under the full integration run, green alone. The branch loses this race intermittently, as upstream does.

jorgens and others added 30 commits August 29, 2026 09:41
…chain

The pin governed nothing. `.node-version` said 24.18.0, ~20 CI steps
hardcoded `node-version: "24"` (floating across the minor line), the PR
bridge used 22 — below the `engines` floor — and `.npmrc` claimed CI read
the pin via `node-version-file`, which appeared in no workflow. Because
engine-strict only enforces the floor, a newer local Node drifted silently.

- Point all 23 setup-node steps (workflows + composite action) at
  `node-version-file: .node-version`.
- Add scripts/check-node-version-pins.sh to `check:drift:guards`: rejects
  literal versions, a partial pin, a pin under `engines.node`, and a
  setup-node step with no version key.
- Drop `ignorePatchFailures` — pnpm no longer recognises it and warned on
  every command. Fail-closed is pnpm's default; verified by corrupting a
  patch hunk and confirming ERR_PNPM_PATCH_FAILED.
- Document the Rust + pkg-config prerequisite: packages/native-config is a
  Rust addon the workspace depends on, so `pnpm run check` ran `cargo test`
  and failed on any machine without it.
- Correct the corepack instruction (gone from Node after 24) and gitignore
  the 13 MB of Excalidraw fonts every build regenerates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both seed-from-disk guards asked the XmlFragment whether a document already
had content. Y.Text is the source of truth (precedent inkeep#38), so that question
was being put to the derived replica: a document holding source bytes whose
fragment had not been derived reads as empty, and the load seeds the file's
bytes ON TOP of the live ones, concatenating disk content into a populated
document.

Both sites now refuse to seed when EITHER surface holds content, which is
strictly more conservative than either check alone and covers divergence in
both directions.

Benefits:
- Closes a content-duplication path in `loadManagedArtifactDoc`, whose guard
  read the fragment alone with no Y.Text fallback.
- Makes the emptiness test agree with the truth contract the rest of the
  server already follows, so the two cannot drift apart later.
- No behaviour change on the ordinary cold load: the seed is a paired write
  that populates both surfaces together, so the two conditions coincide
  there. Pinned by a `control:` row.
- Removes the fragment coupling from the load path, which any change that
  makes the fragment derived-on-demand would otherwise have to carry.

Tests: both new assertions verified non-vacuous (they fail with the guard
reverted). Full server suite 8718 passed / 6 skipped; full monorepo
`turbo run test` 12/12 tasks green; typecheck and biome clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Observer B rebuilt `Y.XmlFragment('default')` from `Y.Text('source')` on every
source-mode keystroke — a full markdown re-parse plus fragment rebuild,
synchronously on the main thread, unconditionally. Measured against the real
observers: exactly 1.00 parse per keystroke, parsing 1.00x the document each
time, at 72% of total keystroke cost. None of that output is read when nobody
is looking at the WYSIWYG.

This adds a demand gate. While no connected peer needs the fragment, Observer B
skips the rebuild and records that a derive is owed; the next fire that sees
demand — or `resumeFragmentDerive` on the awareness transition — pays it back.

Benefits:
- Removes the per-keystroke re-parse entirely for source-only editing, which is
  where the cost was largest and the output least used.
- Turns the fragment into something derived on demand rather than continuously,
  which is the same direction a local projection would take further.
- Gives the invariant watchdog a way to tell by-design staleness from a broken
  bridge, on its own counter, instead of either alarming on normal operation or
  being blanket-suppressed.

Three properties make it safe, each with a test:
- INERT BY DEFAULT. No `fragmentDemand` means the old unconditional behaviour.
  Every existing caller omits it. Shipped behind `deriveDemandGateEnabled`,
  default off, following the six existing bridge kill-switches — a change that
  can make the fragment deliberately stale should not be inherited on upgrade.
- SUSPENSION IS HONEST. `deriveSuspended` is deliberately NOT `suppressDevThrow`:
  that flag means "downstream site, keep going" and still records a violation,
  which is right at a persistence fire. This one means "nothing has asked the
  fragment to track Y.Text yet". Counted on `bridgeDeriveSuspendedDivergences`
  so the documented identity `violations + suppressed` stays intact.
- SUSPENSION IS BOUNDED. Every suspension ends in a catch-up derive that
  re-asserts the invariant at full strength; detach clears the flag. Suppressing
  the watchdog is only defensible because the suppression always ends.

The demand policy fails safe in one direction: anything not explicitly
`mode: 'source'` — a missing field, an older client, an unrecognised value, a
throwing predicate — counts as demanding. A wrong "yes" costs the derive we
already pay; a wrong "no" shows someone stale content.

Agent writes are unaffected (they rebuild the fragment through the paired-write
primitives, not Observer B), but `resumeFragmentDerive` runs before the
before-snapshot in thread-manager so `changedBlockRange` cannot attribute
staleness to the agent's own edit.

Known window, documented at the call site and the main reason for the default:
the catch-up is triggered by the client's awareness update, so a pre-mounted
WYSIWYG can flash pre-edit content for about one round trip on the flip.
Closing it needs a client-side freshness handshake — a protocol change with its
own design.

Tests: server 8748 passed / 6 skipped; desktop 4343 passed (run alone —
worktree and keyring failures in the combined run were contention timeouts at
942s, both pass in isolation and desktop imports none of these surfaces);
typecheck and biome clean. Watchdog rows carry a `control:` proving the same
divergence still throws when not suspended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fragment"

This reverts commit 97cb051.

The demand gate was built to answer a question that has since been answered by
reasoning instead: is the derived WYSIWYG fragment worth keeping fresh? The
project is now collapsing to a single CRDT, so the fragment stops being a synced
replica entirely and becomes a local projection. Every file in that commit was
fragment-lifecycle machinery — the gate, the demand policy, the suspension flag,
the watchdog's suspended-divergence branch, its metric, and two call sites — and
none of it survives deleting the fragment.

Reverted rather than left dormant: the gate was default-off and harmless, but
carrying a kill-switch, a metric series, a policy module and a watchdog
special-case through a migration that deletes the thing they protect is upkeep
with no payoff at the end of it.

The measurement that commit produced is the part worth keeping, and it does not
need the code: Observer B performs exactly 1.00 full parse per source keystroke,
parsing 1.00x the document each time, at 72% of total keystroke cost. That is
recorded in the feature spec, and it is the number that justifies removing the
derive rather than scheduling it.

Kept from the same line of work: 14f29a6, which moved the seed-from-disk
emptiness guards off the derived fragment onto Y.Text. That one is correct on its
own merits today and is a step toward the single-CRDT target rather than
scaffolding around the fragment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0 of the single-CRDT migration, plus the pure core of Phase 1.

`MarkdownManager.parseWithSourceMap()` returns the doc `parse()` would
return plus a `PmSourceMap`. remark keeps a `position` on effectively
every node; the mdast→PM handler layer dropped all of them, because a PM
node has nowhere to put one. The map is built BESIDE the doc rather than
in its attrs: attrs are schema, so they would sync into the CRDT, into
`toJSON`, and into every byte-stability snapshot. A recorder threaded
through a wrapped handler table costs one null check per node when
nobody asks for a map.

Three things the plan did not anticipate:

- `dedentBlockJsxClose` is the one non-length-preserving transform ahead
  of remark, so it now reports its removals and the map re-adds them,
  composed with the BOM strip. The other guards are char-for-char.
- `table` assembles its own rows and cells instead of delegating to
  `state.all`, so they inherited the whole table's span. A count-gated
  structural descent took node coverage from 78.9% to 97.0%.
- Minting `commentBlock` positions fixed a byte-stability bug: with no
  position it reached `insertInteriorBlankRunParagraphs`, which could
  not measure the gaps beside it and silently dropped preserved blank
  runs on the way to disk.

Measured over the 68-document docs corpus: 1918/1918 top-level blocks
mapped from a parse fact, block table index-aligned with `doc.childCount`
on every document, zero ordering or containment violations.

`projection/block-splice.ts` turns a WYSIWYG edit into one `Y.Text`
splice. The changed-block search is longest common prefix/suffix under
`===`: PM nodes are persistent, so a transaction shares every node it did
not rebuild — O(childCount) pointer comparisons, no `prosemirror-state`
dependency, no step interpretation. Serializing one block rather than the
document is required for cost (0.05ms flat vs 181ms at 488KB) and is also
strictly better for byte stability, since a whole-doc serialize
renormalizes blocks the user never touched. Tested on containment and
re-parse fidelity, never against a whole-document serialize — that oracle
disagrees with a correct splice about a tenth of the time and every
disagreement is the oracle renormalizing.

`commentBlock` was the last real-world top-level block without a
position, so it was also the natural trigger for the server bridge's
`missing-position` fallback. One test in map-driven-observer-a now
asserts a comment block takes the splice path; the other drives the guard
through a position-stripping stub so the guard itself stays covered.

No behaviour change otherwise: `parseWithSourceMap().doc` is asserted
equal to `parse()` across the package corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 1 and 2 of the single-CRDT migration, gated by
`PROJECTION_BINDING_ENABLED` (off). The fragment binding is still what
production uses; both paths coexist until the bridge goes.

`projection-binding.ts` binds ProseMirror straight to `Y.Text('source')`.
A local edit re-serializes only its top-level block and splices that
block's line range under the user's own origin — the same translation
Observer A does today, relocated to the client and narrowed from the
whole document to one block. Narrowing is required for cost (0.05ms flat
vs 181ms to re-serialize 488KB) and is also better for byte stability: a
whole-document serialize renormalizes blocks the user never touched,
which is a spurious diff on disk.

Two constraints had to be discovered rather than designed:

- A binding cannot install its document from `view()`. ProseMirror builds
  plugin views inside the EditorView constructor, where TipTap's
  `dispatchTransaction` reaches for a `this.view` that does not exist yet.
  The first attempt dispatched there and the binding wrote the editor's
  EMPTY starting document over the markdown. The projection has to arrive
  as the editor's initial `content`, which is why `createProjectionBinding`
  returns content, extension and undo manager from one call: told about a
  different document than the editor was built from, the binding reads the
  difference as a local edit.
- `MarkdownManager` carries its own `Schema`, so a projected document's
  nodes have foreign NodeTypes. ProseMirror matches content by NodeType
  IDENTITY: `eq()` was false between two documents with byte-identical
  JSON, so every keystroke looked like a whole-document change, and nodes
  inserted directly are silently dropped on the first incremental rebuild.
  Convert through JSON on the way in, then adopt `view.state.doc`.

Typing never re-parses: `rebaseProjection` derives every block span
arithmetically after a splice. Pinned at 20 keystrokes → 20 splices → zero
document parses; an outside write costs one.

Phase 2 falls out. `shared-undo-manager.ts` holds one `Y.UndoManager` per
document over `Y.Text`. Source mode reaches it by handing it to `yCollab`
(y-codemirror adds its own sync origin when it installs); WYSIWYG reaches
it because the projection writes under `PROJECTION_WRITE_ORIGIN`, and
ships the Mod-z keymap alongside the write for the same reason. This is
the target wiring, not the interim hack the plan warned off — that was
injecting a manager into `Collaboration`, which is fragment-based and is
being deleted. `trackedOrigins` includes `null` so source-mode undo is
byte-identical to today with the flag off.

`cross-mode-undo-projection.test.ts` runs both surfaces for real — a
CodeMirror view bound by yCollab, a ProseMirror view bound by the
projection, one Y.Text: LIFO holds in both authoring orders, undo and redo
reach both views, and a two-line source frame survives an interleaved
WYSIWYG edit and retracts whole. That last one is defect 1's exact shape.

Under the flag, four extensions drop out with the fragment they service:
`collaboration`, `collaborationCursor` (resolves through ySyncPluginKey),
`bindingStalenessGuard` (guards a Y→PM apply half that no longer exists)
and `walkCurrency`. Both arms are pinned by test.

Two governance suites caught real omissions: the origin-undoability sweep
wanted a ruling for `PROJECTION_WRITE_ORIGIN` (client-editor-um), and the
window.__ STOP rule flagged the dev-toggle docstring — reworded to prose
rather than allowlisted, since that list attests to a verified DEV gate and
this module only reads the global.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dev:electron` runs under turbo's strict env mode, so an undeclared
variable is dropped before electron-vite sees it — the flag would have
stayed off while looking like the projection path had simply changed
nothing. Declare it in that task's passThroughEnv, and record all three
routes (browser dev server, desktop, `.env.local`) in the spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by typing on the flag path, which is exactly what that step is for:
Enter did nothing, while Shift+Enter worked.

Enter makes an EMPTY paragraph, and markdown has no way to write one — a
blank line is only expressible as a wider gap between two blocks that
themselves emit. So `serializeBlockRange` returned '' for the inserted
block, `computeBlockSplice` keyed its branching off that empty string and
took the DELETION path, that path found an empty source range and returned
null, and null meant "cannot place this edit", which rebuilt the document
from the unchanged markdown. The user's new line vanished as they made it.

The branching now keys off the shape of the edit — insertion, deletion,
replacement — and off whether the replaced blocks occupy bytes, never off
whether the serialized text happens to be empty. A block that emits nothing
is held in the projection with a zero-width span (so the block table keeps
one entry per document block, which is what the rebase and the next
splice's indexing rely on) and written as nothing at all: no CRDT
transaction, since an empty one would still wake every observer and land an
undo item that undoes nothing. It materializes into bytes the moment it
gets content.

Two supporting fixes:

- `blockRangeToSourceRange` no longer widens a zero-width range to its
  enclosing line. A zero-width span is an insertion point; widening it made
  the next edit overwrite the neighbour the empty block sits against.
- A point-write's anchor and its blank-line separator are now one decision.
  Choosing the side independently put the separator on the far side of the
  anchor from the neighbour it was measured against, landing inserted text
  inside that neighbour's gap — caught by the existing mid-document
  insertion test.

Also recorded in the spec's traps: a parse of what the projection wrote is
not structurally identical to the document that wrote it (a stranded
leading space comes back carrying a `sourceLiteral` mark), and interior
blank runs authored in WYSIWYG still do not reach the markdown — unlike the
empty paragraph, those DO have a spelling, so that one is a real gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second report from typing on the flag path: several blank lines added in
WYSIWYG showed there, collapsed to one in source mode, and reappeared in
WYSIWYG — the last part only because the editor instance is cached and
still held blocks the CRDT never received.

Blank paragraphs emit no markdown at any count, so serializing the changed
block yields the empty string whether there is one blank or five, and the
run was held as unwritable. But markdown does spell them, as the gap
between the blocks on either side: `insertInteriorBlankRunParagraphs` reads
N blanks out of a gap of N+2 newlines, and the doc-edge pass reads them out
of N+1 trailing newlines from MIN_CARRIED_EDGE_EMPTIES up.

So the write is arithmetic on newlines between the run's emitting
neighbours rather than serialization of its blocks. That is also
byte-minimal — only the newlines between two blocks are rewritten, never
the neighbours themselves, so no untouched block gets renormalized.

The run is re-derived from the current document rather than taken from the
changed range: adding one blank line to an existing run changes one block
but has to rewrite the whole run's gap.

Two cases stay held and unwritten, both deliberately. A single TRAILING
blank is below the doc-edge floor, where an empty paragraph is
indistinguishable from the type-here affordance the editor renders after
the last block — the parse side refuses to carry it, so this side must not
write it. A LEADING run needs the boundary-capture path and is not handled.

`rebaseProjection` declines a whitespace-only rewrite: those blocks occupy
no bytes and their parsed positions are not reproducible by the newline
arithmetic, so the caller re-derives from the markdown instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding blank lines in WYSIWYG worked after the last fix; deleting them
did not. Removing a blank reaches the write path as a deletion of a block
that occupies no bytes, which the ordinary deletion branch declines — so
nothing was written, the editor showed one fewer blank line than the
markdown held, and the next re-projection handed the deleted line back.

The gap arithmetic already covered this; it was only reachable from the
add side. Route any edit that adds OR removes blank paragraphs through it,
before the deletion branch. A count of zero needs no special case: the gap
becomes the ordinary two-newline separator.

Emptying a paragraph of its text is a gap edit as well, not a block
deletion — the block is still there and still renders as a line, so it
becomes the blank line it now looks like rather than disappearing from the
markdown while staying in the editor.

A trailing run that falls below MIN_CARRIED_EDGE_EMPTIES is now written as
NO run rather than left alone. Leaving it would keep more blank lines in
the markdown than the editor shows, and the next re-projection would give
back a line the user had just deleted.

One trap this exposed, and the reason three existing tests went red before
it was handled: a gap rewrite that computes back to the bytes already
present must be DECLINED, not returned. A below-floor trailing run does
exactly that, and reporting it as a write makes the caller record a write
it did not make — the block table ends one entry short of the document,
and the next keystroke indexes past the end of it and is lost. Declining
sends the caller to the zero-emission hold, which gives the block a
zero-width span and keeps the two aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what landed and where (§5), the invariant that generates most of
the trap list, the manual-test checklist that is the actual gate on Phase
3, and the verified suite state.

Two things the next session should not have to rediscover:

Every bug found so far came from typing on the flag path and none from the
test suite — three in the first session of manual use, all the same shape:
the ProseMirror document is strictly more expressive than markdown, and
anything it can hold that markdown cannot spell needs an explicit answer.
The checklist marks what has been exercised and what has not; lists,
tables, paste and JSX components are untouched and are where the next one
is likely to come from.

And `map.blocks.length === doc.childCount` is the invariant behind those
three bugs. Its symptom shows up one keystroke LATER than the edit that
broke it, because a misaligned block table loses the NEXT edit rather than
the one that caused it.

Also un-exports PROJECTION_BINDING_ENABLED, which nothing imported once
the flag moved behind `projectionBindingEnabled()` — restoring exact knip
parity with the pre-change baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot spell

Two pieces of the single-CRDT projection, both in core because both surfaces
need them.

`source-blocks.ts` computes top-level block ordinals from markdown alone. That
coordinate was previously derived twice — the app from an mdast parse, the
server from the `Y.XmlFragment`'s children — and the migration deletes the
fragment, so this becomes the surviving definition. Dependency-light on
purpose (mdast positions and string slicing, no ProseMirror) so the server can
index ordinals without building a document.

`alignProjectionToDoc` re-holds trailing empty paragraphs with zero-width
spans. An empty paragraph has no markdown spelling, so any rebuild re-parses
bytes that cannot reproduce it and the block table comes back one entry short
of the document. That breaks `map.blocks.length === doc.childCount`, the
invariant every splice indexes through, and both consequences are silent: tail
edits become unplaceable and are discarded, and `rebaseProjection` refuses
outright so every keystroke falls back to a whole-document parse.

Found by hand — Enter twice out of a bullet list, then type, and the text
lands at the end of the first bullet. The suite could not see it because every
fixture ends in a paragraph and none rebuild after an Enter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3's first port. `snapshotBlocks` read the `Y.XmlFragment`'s children to
diff which blocks an agent write changed; it now parses `Y.Text` through
core's `sourceBlockSnapshot`. The app's `block-spans.ts` re-exports the same
core function, so app and server share one definition of a block ordinal
rather than two that happen to agree.

Block identity is the block's own source bytes, which is sharper than its
plain text: a link-target rewrite changes the bytes while leaving the text
identical, and the old text-based identity would have reported no change and
flashed nothing.

The cost is a parse per snapshot where the fragment read was a walk. Bounded:
twice per agent thread write, on a path that already parses the payload, never
on a keystroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urface

Phase 3's second port. `replayBufferedContent` compared both CRDT surfaces of
a pre-recycle replica against the server to decide which held the un-drained
edit; under the projection it reads `Y.Text` alone and skips the fragment
rebuild entirely.

The plan said the `ok-buffer-replay-diverged` arm "becomes unreachable" once
there is one surface. That was wrong, and the distinction matters. The
fragment was not a second opinion about the edit — it was a standing record of
the ACKED BASE, because only the server's Observer B ever wrote it. Removing
it does not make the arm unreachable, it makes it UNDECIDABLE, and an aged
buffer then splices straight over content the server rebuilt from disk.

So the base is now recorded deliberately: snapshotted at each `synced`
alongside the `lastServerSyncedSV` already captured there, carried on the
buffer, and persisted through the outbox so a tab-crash recovery keeps its
witness. Deliberately NOT taken beside `lastDiskAckedSV`, the stricter
watermark — a disk-ack lands asynchronously and describes an earlier state
than the doc holds when it arrives, so reading text there would record content
the server never acked.

Two conservatisms, both failing toward the delta fallback, which MERGES where
a content splice REPLACES: no base recorded declines rather than splicing
blind, and a base coarser than the fragment's costs a refusal rather than a
wrong write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by typing, neither visible to the suite.

A `jsxComponent` serializes from the `sourceRaw` slice captured at parse time,
not from its children, so a WYSIWYG edit inside one emitted the stale capture
and was silently discarded — it stayed on screen and vanished at the next mode
switch. `deriveStructuralFreshness` is what notices the divergence and
re-derives; under the bridge the SERVER serialized and its manager has always
had the flag, so moving the serialize onto the client lost the coverage with
the move. The projection now takes its own manager rather than the clipboard's,
which also backs copy/cut/paste where re-deriving is not obviously wanted.

`Y.UndoManager` merges frames by elapsed time alone — no origin check — so
once both surfaces write the same `Y.Text` a source edit and a WYSIWYG edit
inside the 500ms capture window became ONE stack item and a single undo
retracted both. Every existing row in `cross-mode-undo-projection.test.ts`
calls `breakFrame()` between edits, so none could see it; the product had
nothing playing that role. `handleModeChange` now closes the frame at the mode
boundary — a natural boundary for the user, and reaching the other view
requires passing through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… root path

Two subsystems that read `Y.Doc` state but had been wired through the bridge.

`attachQuiescenceTracker` was called from inside `setupServerObservers`, so a
document the bridge declined never got one. Its counters start equal and
`isDocQuiescent` is `settledGen > lastUserTxGen`, so an untracked doc reports
NOT quiescent forever — and persistence gates every write on exactly that,
deferring each store indefinitely. Tracking reads transactions only and has
nothing to do with the fragment; it now attaches per-document from the
extension, outside every bridge skip.

`relative(contentDir, contentDir)` is `''`, and `ignore` throws "path must not
be empty" on it, which aborts the whole parcel batch and silently drops every
other event in it. A raw watcher event on the content root reaches the filter
that way. `contentRelativePath` already guards the same case for the folder
index; both `ContentFilter` implementations now do too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The projection is the only client path and the markdown bridge is never
attached. `Y.Text` is the only live CRDT.

- `projectionBindingEnabled()` returns true unconditionally; the flag constant
  and both dev-only override channels go with it.
- The observer extension never attaches observers. What survives there is the
  per-document quiescence tracker, which was never bridge logic.
- Port 3 — both emptiness guards read `Y.Text` only. The fragment halves asked
  a derived replica whether a document had content; with nothing deriving it,
  they could only ever answer wrong.
- Port 4 — persistence no longer runs the bridge-invariant check or its repair
  arm. Left unguarded it reported divergence on every write and took the
  repair each time, minting a checkpoint into the user's version history and
  rebuilding a replica nothing reads. Nothing about the bytes on disk changes:
  `Y.Text` was already the source of truth (precedent inkeep#38).

`OK_DISABLE_BRIDGE` was scaffolding for the manual pass and is gone from
`turbo.json` with it.

The observer machinery, its tests, and the fragment-path client modules are
still present but unreachable; they come out next, along with the four unit
tests that assert the fragment arm and now fail by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uites

The four defect-characterisation suites were untracked and load-bearing for
verification; one of them failed `biome check` on formatting and had been
blocking `pnpm run lint`. Formatted and committed so they stop being one
`git clean` from gone.

Spec updates from the manual pass:

- The §8 dev command now sets both halves. The old one satisfied neither the
  requirement stated directly beneath it nor any way of satisfying it — there
  was no server-side switch at all until one was added.
- Records the symptom that led there, because it points anywhere but at the
  bridge: typing jumps the caret to the previous edit point while Enter works
  perfectly. Enter writes ZERO bytes, so the drain never wakes; only
  byte-writing edits lose the race.
- Ports 3 and 4 reclassified. Called ports, then "behind the manual gate";
  both wrong, and the second wrongly implied the pass could be completed
  without them.
- Suite reliability: grep `AssertionError` before believing a failure. Every
  spurious failure here is a timeout, and timeouts are self-amplifying — the
  same 20 files took 1,211s and failed 4 at the default budget, 249s and
  failed none at 300s. Raise the budget to triage; do not bisect the file list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in 32 upstream commits. Four land on this work:

- e94c95a enforces a default-deny no-comments policy. Its sweep stripped
  the prose from every file this branch touches, so most conflicts were our
  comments against upstream's deletion of them. Resolved toward the code.
- a749cea drops TipTap's TrailingNode. Two knock-ons resolved here:
  `comparableChildCount` takes upstream's form, and the edge-blank floor is
  now split, so the trailing gap write reads MIN_CARRIED_TRAILING_EMPTIES.
- 280bc35 adds a source-undo flip tracker that clears the undo stack on
  return. It is wired to the shared manager rather than a source-only one,
  which means its clear() now reaches both surfaces. Preserved as-is: it
  guards against destructive undo, and whether the single-CRDT path still
  needs it is a question for a test, not for a merge resolution.
- a8dae21 touches sync status, which the wedged-document work will revisit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream split MIN_CARRIED_EDGE_EMPTIES into a leading floor of 2 and a
trailing floor of 1, because dropping TrailingNode makes a single trailing
blank distinguishable from the click affordance.

Adopting the trailing floor of 1 in the block splice broke the projection.
The write path depends on a lone trailing empty paragraph staying unwritten
until it gains content: with the floor at 1, Enter wrote a blank line, and
typing into that paragraph then materialized the block without reclaiming
the line. "Enter, type, Enter, type" ended with a trailing newline the
document did not have, and the doc/source equality assertion beside it
failed.

The two floors answer different questions — one is disk carry fidelity, the
other is whether an editor-held block gets bytes — so the splice keeps its
own named constant. Whether the projection can now adopt 1, and reclaim the
line on materialization, is a separate change with its own tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's no-comments policy (e94c95a) rejects prose in source files at
error severity, and the projection modules carried ~640 lines of it. The
knowledge was load-bearing, so it moved rather than went: §10 of the
migration spec is now an architecture reference for the coordinate system,
the byte map, the write path, the binding, undo and replay attribution.

Removal was driven by the policy's own predicate (analyzeFile from
lint-plugins/no-comments) rather than by hand, so the cleanup and the gate
agree by construction. `pnpm run lint` is clean, including no-comments.

Suites unchanged: core 3901 passed; app 15 failed, byte-identical to the
pre-merge failing set; server 21 failed, unchanged across the edit. Those
reds are the fragment-path arms the cutover stranded, and are Phase 1/2 work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Phase 0 comment sweep removed every prose comment, including a handful
that the policy's own allowlist admits. CONTRIBUTING documents the class:
a comment beginning STOP: (a cross-file contract a reader must not break) or
WARN: (a sibling that silently drifts if this changes) is a contract-marker,
not prose.

Five come back, each a constraint whose violation fails silently:

- block-splice: map.blocks.length === doc.childCount, the contract every
  splice indexes through. A violation loses the NEXT keystroke, not this one.
- projection-binding: the write stays one delete plus one insert; a
  character-minimal diff reintroduces the stale-anchor content-loss class.
- projection-binding: MarkdownManager owns a separate Schema, and ProseMirror
  matches content by NodeType identity, so nodes inserted without the JSON
  conversion are dropped on the first incremental rebuild.
- shared-undo-manager: PROJECTION_WRITE_ORIGIN is an identity to track, not
  an origin to write under.
- block-spans: comparableChildCount is a tripwire, not a proof of alignment.

A `//` marker is one line by rule — the linter says so directly — so all five
are block comments. The rest of the prose stays in the spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature-specs/ is a local working area — the migration guide, the research
record, and the drafts for approaching upstream. None of it belongs in the
public mirror, which is generated from an allowlist and has no such directory.

Ignored rather than deleted: the notes stay on disk and stay useful, they just
stop being something a branch can carry into a pull request. Prior commits that
added or edited them were rewritten out of this branch's history; the versioned
copy lives on single-crdt-specs-archive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md has linked README.md#running-desktop-with-browser-access since
c06b0d5, but the section it points at was never written — the link has
been dangling ever since.

Documents why `pnpm --dir packages/desktop run dev` cannot serve a browser
client (electron-vite sets ELECTRON_RENDERER_URL, the window manager then
sends no reactShellDistDir, and the server drops the `ui` capability), and
the build-then-launch path that does. Includes the ELECTRON_RUN_AS_NODE=1
trap that VS Code terminals inherit, which fails the launch at the first
electron import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2a of the single-CRDT migration: take the fragment off the shipping
write path. Nothing reads it any more — each client derives its own
ProseMirror document from `Y.Text` locally — but the server was still
building and diffing one on every store, every agent write, every
file-watcher write, every rollback, and on every asset event touching a
document that referenced the asset.

Y.Text remains the source of truth for the bytes that reach disk, and that
path is untouched. Byte stability was verified across headings,
source-form delimiters (`__foo__`, `~~~`), frontmatter, CRLF, trailing
blank runs, doc-start thematic breaks, tables and nested lists.

bridge-intake:
  `composeAndWriteRawBody` and `replaceRawBody` become Y.Text-only. With no
  fragment to feed, the whole-document parse they performed had no consumer
  either, so an agent write is now a line-aligned diff and nothing more.
  Their embed-resolver, pre-parse and loss-detector parameters went with it,
  which narrows `applyExternalChange`, `applyAgentMarkdownWrite`,
  `applyAgentUndo`, `createExternalChangeHandler` and
  `reconcileDiskBeforeAgentWrite` in turn.

  `deriveFragmentFromYtext` was purely a fragment derive, so agent-undo is
  now `um.undo()` inside the transact and nothing else. The paired-write
  enforcement gate learns that `Y.UndoManager.undo()` is itself the write
  on that path rather than a bypass of one.

persistence:
  Drops the `json` half of `captureDocSnapshotForPersistence`, the
  `assertBridgeInvariant` call (side-effectful: it incremented counters,
  emitted tolerance events and wrote JSONL evidence on every store),
  `reconcileFragmentNow`, `checkpointBeforeReconcile`, the always-zero
  `fragmentChildren` fields, and the fragment conjunct in the seed-from-disk
  guard. `BRIDGE_DISABLED` goes with them: its own note said it lasts until
  the last fragment read comes out, and this was the last one.

client:
  `setupObservers` and its provider-pool wiring are gone — both callbacks
  were already empty. `observers.ts` reduces to the `ORIGIN_*` identities
  and `markUserTyping()`. The buffered-replay path loses its fragment arm,
  which also retires one of the two remaining `projectionBindingEnabled()`
  call sites.

The paired-intake loss detector is removed from the intake primitives. It
compared a pending fragment serialization against arriving bytes to catch a
never-propagated keystroke; with one replica that class of loss cannot
exist, so the comparison has no meaning. The checkpoints on those paths are
untouched and still capture at-risk content.

Checkpoint metadata keeps `fragmentChildren` as an optional field and the
parser accepts entries without it, so timeline entries written before this
change stay readable and restorable.

Known consequences, measured against a `3d96b9fe` worktree:

  - Two back-to-back API writes to one document can now land inside a
    single contributor-flush window and produce one history entry instead
    of two. The writes themselves are correct; only history granularity
    changes. Reproducible, and a 500ms gap between the writes restores the
    old count.
  - `persistence-divergence-realign` no longer emits its `detector-trip`
    ring event. The checkpoint still fires and still contains the at-risk
    line.
  - Nothing re-resolves embeds server-side after an asset event. That
    refresh already reached no client under the projection binding, so the
    gap predates this commit; the dead loop is merely removed here.

Suites, each attributed against a measured baseline rather than assumed:
core and desktop clean; app unit and DOM match their baseline failure sets
exactly; the 55 new server failures and the integration deltas are confined
to bridge, observer, pre-drain and paired-intake suites that Phase 2b and
2c delete wholesale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`skill-restore` wrote two skill versions back-to-back and immediately expected
two history entries. It passed only because the pre-2a write path was slow
enough to push the second write past the contributor-flush window.

With the fragment derive gone, both writes land inside one window and share a
single entry — confirmed permanent, not merely delayed: two back-to-back
writes never reach two entries, even given 30s to do so. That batching is
accepted behaviour, so the test is what changes.

It now waits for each write to reach history before making the next one, which
is what it actually needs to have an earlier version to restore *to*. The
contract under test — restore reverts the source to an earlier version — is
unchanged and no longer depends on how long a write happens to take.

The changeset gains the user-facing half: rapid successive writes to one file
can share a version-history entry. Nothing is lost, and edits seconds apart
still get their own; there are simply fewer, larger steps to step back through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2a took the ProseMirror fragment off every shipping write path, leaving the
bridge apparatus running against a tree nothing wrote to. This removes it.

Deleted: server-observers.ts, bridge-watchdog.ts, pre-drain-discriminator.ts,
map-driven-splice.ts, bridge-loss-suppression.ts, and the 39 suites and rigs
that existed to test them. The blast radius ran through two shared rigs --
bridge-race-rig.test-helper.ts into pre-drain-wired.test-helper.ts -- which is
why it reaches further than the module list suggests.

server-observer-extension.ts loses its last BRIDGE_DISABLED and collapses to
the quiescence tracker, its only remaining job. That tracker is the persistence
settle gate, not a bridge remnant: a document with no tracker never reports
quiescent and so never persists. server-factory stops reading the three bridge
guard keys that only fed the disabled path; they still parse.

PairedWriteOrigin, isPairedWriteOrigin and OBSERVER_SYNC_ORIGIN move to a new
write-origins.ts ahead of the deletion, so the thirteen importers that needed
only those identities no longer reach into the machinery.

bridge-loss-detector.ts and loss-capture.ts are NOT deleted, against the plan's
list. Both have live non-bridge consumers: persistence.ts compares live content
against disk at three sites through detectPairedIntakeLoss, and the CLI's
`ok diagnose` bundle reads the loss-capture ring.

Two files repaired rather than deleted. bridge-loss-detector.test.ts drops to
its three pure-function describes -- everything else seeded a fragment, and it
was already failing at HEAD on a deriveFragmentFromYtext import 2a removed
(test files are excluded from typecheck, so nothing caught it). The extension's
bridge-disable test is replaced by a quiescence-lifecycle test; its "bridge is
declined" assertions had gone vacuous.

The 19 bridge-era metrics counters are marked @deprecated rather than removed:
the payload shape is unchanged and knip still reports them, so the irrelevance
stays visible instead of being silently retired. The six persistence
reconcile-loss counters are deliberately left untagged -- those measure an event
that still happens whose instrumentation 2a dropped, which is a Phase 3 repair,
not a retirement.

Server suite: 76 failures -> 19. Every remaining failure is pre-existing --
14 in the 3d96b9f baseline, and 5 (qa-watcher-intake-lens, reconcile-intake-
loss) verified by running them at HEAD. Typecheck 11/11, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt arm

projectionBindingEnabled() returned a constant true, so buildPatternDConstructorOptions
took the projection branch on every shipping path and the fragment arm was already
unreachable. The seam and that arm are gone, and `projection` is now a required field
on BuildEditorOptionsArgs -- the fragment path is unrepresentable rather than merely
unreachable.

Deleted with it: the initProseMirrorDoc prewarm walk and its prebuiltMapping,
buildPrewarmBoundCollaboration, the @tiptap/extension-collaboration import,
walk-currency-extension.ts and binding-staleness-guard.ts with their onWedged /
WedgeDetail plumbing, and the two `...(projection ? [] : [...])` arms. The four app
suites that existed only to exercise the deleted arm go too: pattern-d-walk-currency,
pattern-d-schema-identity, and the two module tests. Three of those four were already
failing in the 3d96b9f baseline.

walk-currency-test-harness.ts is NOT deleted, against the plan's list. The sweep the
plan asked for found 30 importers, most of them unrelated to the bridge -- paste,
autolink, math input rules, slash commands. It is a mixed file: generic JSDOM and
clipboard rigging plus fragment seeders. Only the six exports that lost every consumer
were removed, same judgement as bridge-loss-detector.ts in 99676c2.

comparePmStructural, isParseEquivalentBridge and BridgeMergeContentLossError are NOT
deleted, also against the plan. BridgeMergeContentLossError is live: merge-three-way.ts
throws it at five sites and http/catch-errors.ts catches it under a STOP rule, all
external-change merge rather than the ProseMirror bridge. Neither of the other two is
knip-reported, and isParseEquivalentBridge feeds the integration harness's
assertBridgeInvariant, which 2c retires -- removing its dependency first would leave a
broken intermediate.

The mount-promise pre-construct yield stays. Its docblock justified it by
initProseMirrorDoc, but construct() now calls createProjectionBinding -> buildProjection,
a synchronous whole-document parse, so the yield is more load-bearing than before, not
less. Only the stale rationale changed.

Checkpoint mint sites were left alone. Production has zero mint sites for the six dead
bridge kinds already, so the plan's instruction was satisfied before this commit;
removing mint capability from shadow-repo's params union instead broke 21 server tests
that use those kinds as fixtures for generic checkpoint machinery, GC budget isolation
included. Reverted.

The two ORIGIN_* identities move into the integration harness that is their only
consumer, and the now-stale rows come off origin-undoability-sweep's contract table.
walkYTextItems is un-exported rather than deleted -- agent-activity.ts uses it three
times internally. Knip: 75 -> 72 unused exports, exactly those three, nothing newly
orphaned.

bridge.deferGuard, bridge.fixedPoint and bridge.preDrain descriptions now record that
they are deprecated and unread; they still parse. backgroundThrottle, flushOnHide and
lossDetector are untouched -- all three still gate live behaviour.

renderCursor is deleted with the yCursorPlugin arm. Phase 5 plans to reuse it and will
need it back from this commit's parent; it resolves through ySyncPluginKey, so the arm
could not be re-enabled in place regardless.

Typecheck 11/11, biome and lint clean. Server 8,751 passed / 19 failed and desktop
4,385 passed both match the recorded figures exactly; conversion holds at 80/25, so
byte stability is unchanged. App unit drops from five baseline failing files to one
pre-existing. Integration is 248 failed / 76 files against a 220 / 69 baseline that
predates both 2a and 99676c2; every new file carries a fragment-era signature
(empty-fragment assertions, the harness bridge invariant, ENOENT on server-half
deletions), but no HEAD baseline was measured, so that attribution is by signature
rather than by diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jorgens and others added 26 commits September 15, 2026 18:29
9 upstream commits, 4 content conflicts.

package.json test:e2e: kept the branch's list and added upstream's new
agents-panel-reload-visibility.e2e.ts. Upstream's list still names
observer-a-multi-client and mode-switch-ordinal-divergence, which the
branch removed; the membership guard passes.

timeline-and-recovery.mdx: took upstream's new paragraph on automatic
recovery entries and kept the branch's undo paragraph.

external-change.ts: kept upstream's insert-dedup-skipped warning and
called applyExternalChange without the embed resolver and bridge loss
reporter.

persistence.ts (1a8b736): took the absent-base tripwire baseline
(baseBytes from duplicationBaseline) without the fragmentChildren log
field, which read the deleted fragment.

Tests 1a8b736 added against the fragment, judged case by case:
- Ported (the behaviour survives; the fragment was only how the test
  made an edit): the two absent-base tripwire cases in
  persistence-tripwire-paste.test.ts now double the document through
  replaceSource, and eleven server-factory.test.ts cases (refused-store
  rescue, destroy() and teardown rescues) append their unflushed edit
  to Y.Text instead of inserting a fragment paragraph.
- Removed (bridge-only): the four failed disk-authoritative ingest
  cases. They forced applyToDoc to fail by making getXmlFragment throw
  BridgeMergeContentLossError; on this branch the ingest is a Y.Text
  splice that never reads the fragment, and two of them failed between
  the Y.Text write and a fragment rebuild that no longer exists. The
  catch they covered stays as upstream wrote it.

Both files pass, 182 / 182. Typecheck, pnpm run lint and the e2e
membership guard pass.

Knip adds one finding, rawSegmentMatchesValue in wiki-escape.ts, which
upstream also exports and uses only inside that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gnature

Upstream's new stale-external-write-gate case (1a8b736) and two
reconcile-own-flush-window calls pass upstream's argument list, with an
embed resolver and a bridge loss reporter before the conflict authority.
The branch removed those two parameters, so the authority landed in an
unused slot and conflicts was undefined: the new case threw on
conflicts.raise, and the two older calls passed only because their path
never raises. The calls now pass the authority fifth.

The test still makes sense without the fragment: it pins that a
reconcile against a blockless acknowledged base is refused, raises a
reconcile conflict and keeps the live document. Both files pass alone,
25 / 25; this was the one server unit red at e97b44e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four comments described the architecture this branch removed:
- RawMdxFallbackCMView: a data flow routed through y-prosemirror and a
  CRDT ProseMirror change; the WYSIWYG now writes Y.Text through the
  projection binding.
- apply-by-prefix-suffix: cited precedent inkeep#10, "XmlFragment-authoritative,
  Y.Text mirrors".
- persistence canonicalizeForEphemeralBaseline: "fragment must catch up".
- agent-sessions: said isPairedWriteOrigin gates paired writes and that
  paired: true makes observers short-circuit; the observers are deleted
  and isPairedWriteOrigin has no production caller.

Deleted rather than rewritten, per the no-comments policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A remote Y.Text change re-parsed and replaced the whole document on every
client: about 0.7 s per keystroke at 488 KiB, and 65-76% of the reader's
main thread at 64-128 KiB with one peer typing (main: 0%).

- core reprojectChanged reparses a window of top-level blocks. It is
  trusted only when the preprocessed text splits cleanly at the window's
  edges (JSX tags pair document-wide) and both anchor blocks reparse
  identical; otherwise the full parse runs.
- JSX attributes no longer carry parse positions, which made every
  component below an edit a different node.
- A remote edit replaces only the changed blocks, synchronously, so undo
  and caret carrying are unchanged.
- Remote carets and the agent flash share the binding's resolver.

Two SelectionAnnouncer cases and one list-drag case that were test.fails
now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A peer keystroke on a ~480 KiB document must replace one block, parse no
whole document, keep the caret, and cost under a tenth of one full parse;
a full-precision lookup after a local keystroke must stay under the same
budget and equal a fresh parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… path

After the peer-edit fix, a book-length document still froze after every
pause in typing: the word count and the lint pass each parsed the whole
document, lint twice, and the chunk wrapper rebuilt a decoration for
every block on every state.

- Word counts run in a worker, with the same function on the main thread
  where no worker is available. One pass is in flight at a time; a change
  during it causes exactly one follow-up.
- Lint takes block spans from the binding's projection when it matches
  the source, and parses as before otherwise, including every source whose
  strict parse fails.
- Chunk wrappers are plugin state, mapped through each transaction and
  rewrapped only in the changed top-level blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The word-count worker died on load and every count fell back to the main
thread, which is what it was meant to leave: `decode-named-character-reference`
reads entities through `document.createElement` in the build bundlers pick
under the `browser` condition, and a worker has no `document`. The page saw
an uncaught error per pass.

Aliased to the package's own default build, which decodes from a table --
what Node and the package's `worker` condition use. Vite 8 has no
worker-scoped resolve options, so the alias is global; same decoder, same
results. Resolution falls back to null, leaving the browser build alone,
rather than breaking the bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
knip reported it as an unused export; callers read the fields structurally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 upstream commits, 2 conflicts, both content, both in core and both
adjacency only: upstream's new parser-reservations module (d53fece,
PRD-8392) lands its export and its import next to the branch's pm-source-map
ones.

core/src/index.ts: kept both exports, upstream's
isMutatingParserReservation ahead of the branch's PmSourceMap types, which
is where the path sort puts it.

core/src/markdown/pipeline.ts: kept both imports on the same rule, and kept
the branch's preprocess helper at both call sites. Upstream replaced the
inline chain encodeEntityRefs(protectFromMdx(encodeBackslashEscapes(s)))
with protectParserSource(s) and dropped the three imports; preprocess held
that same chain, so its body now calls protectParserSource and the helper
keeps carrying dedentEdits into dedentBlockJsxClose, which upstream's
call sites do not do.

No invisible conflict this time: upstream's nine commits touch acp launch
and release-age, generated-index literals, startup skill reconciliation and
four release resets, none of which reach the removed bridge helpers.
Typecheck 12/12 and lint both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's d53fece (PRD-8392) makes the parser honour an escaped
punctuation mark instead of dropping it. Typing a URL without a trailing
boundary authors `https\://a-side.com` -- it did so before this merge too --
and that escape now survives the round trip, so the receiver projects
literal text where it used to project a link.

The branch's contract is unchanged: a reader still renders exactly what the
bytes parse to, and still authors nothing. Only the bytes' meaning changed,
so the two cases now assert the rendering that meaning produces.

- Both peers render the boundary-less URL as literal text; only the
  boundary-typed URL, which authors bare bytes, becomes a link.
- The Y.Text waits pin the escaped form, so a regression in either the
  write path or the parser fails here rather than further downstream.
- The receiver's text is read off the ProseMirror doc: innerText
  interleaves the remote caret's label with the paragraph.

Measured at 495a9fc and 4f02739: 8/8 before the merge across four runs,
2 failed / 6 passed after it across three; 8/8 with this change across three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 upstream commits, 9 conflicts. One lands on the write path this branch
rewrites: 87db543 (PRD-7872) refuses a whole-document replace when another
writer just touched the document. The other three are a turbo.json gate, a
version reset, and ACP model discovery.

server-observers.ts (modify/delete): upstream added an external-editor
recency signal there; this branch deleted the file with the markdown bridge.
Re-homed onto bridge-quiescence.ts, whose attachQuiescenceTracker is the
live per-doc afterTransaction hook and already owns the Date.now() calls.
Upstream set the stamp inside the fragment and ytext observers, which fire
only on a real change, so the doc-level hook gates on tx.changed.size > 0 to
keep that precision.

agent-write-spine-files.test-helper.ts (modify/delete): the previous merge
dropped it along with its two bridge censuses. The helper itself is generic
-- it walks src for applyAgentMarkdownWrite -- and 87db543 adds two live
consumers, so it comes back at upstream's version.

agent-sessions.ts: kept upstream's CONCURRENT_REPLACE_WINDOW_MS,
AgentWriteRecency and assertConcurrentReplaceAllowed; dropped the
embedResolver, precomputed-parse and loss-detect parameters this branch
removed. suppliedWriterId is therefore parameter 4, not 7, and every call
site follows: agent-write-routes.ts, lint-write-routes.ts, api-extension.ts
and thread-manager.ts. thread-manager.ts keeps upstream's try/catch turning
the refusal into a -32009 RequestError, which is the feature's error surface.

Two invisible conflicts, neither marked by git:

Upstream's 11 new tests in agent-sessions.test.ts merged clean but still
called the 7-parameter shape, so sessionWriterId(...) landed in a dropped
slot, the guard saw no writer id and 6 refusal tests passed nothing through.
Rewritten to the 4-parameter signature.

agent-write-spine-walk-root-coverage-contract.test.ts asserts at least two
spine censuses. Upstream has three; this branch has one, because the
loss-detect and pre-drain censuses went with the bridge. Floor lowered to
one with a message saying what it guards. Its real assertion, that every
census walks the same root, is untouched.

doc-edge-blank-runs.test.ts: dropped upstream's new fragment-only merge-seam
test, which needs the ProseMirror fragment this branch removed, and kept its
rename of the trailing-run test, whose body now retries the agent replace
until the guard window clears.

Typecheck 12/12, lint clean. Server 10241 passed / 1 failed, core 4534
passed, app 9340 passed, and the resolved integration file 10/10. The single
failure, managed-runtime-flow's real-npm admission probe, fails identically
at the pre-merge tip: it needs a registry this environment does not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's #4430 arrived with the 79e2f86 merge and seeds its rich-text
case through `client.fragment`, which this branch deletes; the case died on
`Cannot read properties of undefined (reading 'push')`. It is the first full
suite run since that merge.

Seed it the way the other ported suites do, through the harness's
`appendProjectionParagraph`, which writes the paragraph into `Y.Text` via the
same block splice the WYSIWYG uses. The distinction the upstream case draws
between a source and a rich-text edit is what this branch collapses: both are
`Y.Text` writes under the author's own origin, and the gate reads
`getLastExternalEditorChangeMs` either way.

Evidence: the file runs 11/11; app integration re-run clean is 1,745 / 4, and
none of the four is this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two PRD-8464 guards type into the document and then have an agent replace
the whole of it. Upstream's #4430 refuses a whole-document replace within
`CONCURRENT_REPLACE_WINDOW_MS` (2 s) of an editor change, so both died on
`agent-write-md failed: 409`. Neither case exists upstream, and the upstream
file passes 2/2 on a clean 9ee18ed worktree, so this is the guards meeting
upstream's new contract, not a behaviour difference: upstream's own
concurrent-replace test asserts a recent rich-text edit is protected too.

Retry the replace until the window passes, the idiom
`jsx-unregistered-ime-concurrent.e2e.ts` already uses, so the guards keep
testing undo after an agent rewrite rather than the refusal gate.

Evidence: the file runs 6/6; the full e2e re-run is 802 / 3, and neither guard
is among the three (each of those passes in isolation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tream

The 30 were written phase by phase, and read as one release they contradicted
each other and the code: two claimed `bridge.lossDetector` still controlled
live behaviour while a third deprecated it, one said remote carets "remain
absent" and another said they were back, and several described "the previous
release" meaning an earlier commit on this branch.

Worse, most described round trips. Each claim was checked against
`upstream/main` rather than against the branch's own history, and these turned
out to be defects this branch introduced and then fixed, so upstream users
never saw them: same-paragraph duplication (spec §9.2), same-mode remote carets
(§9.3), caret and selection survival across a peer edit (`ddc9f3db3`: "Upstream's
fragment held the space"), the agent-rewrite caret (`e7df69ddb`: on main ySync
maps it), the source-mode paste stall (`636c8e19a`: 1,140 ms here against
16.8 ms at the upstream parent `d4218be0`), the conversion-undo steps
(`f196137c5`: the test passes at `d4218be0` with byte-identical files), the
View-in-source button and the mode-switch landing (`515eca737`: "behaves as it
does on main"; upstream's getYDoc finds the `collaboration` extension this
branch deleted), the scoped MDX fallback (`daa030b90`: upstream's
parseWithFallback already scoped by region), and the blank-line and
trailing-space families, whose mechanisms are branch-only.

What is left is nine entries, each a real difference from `v0.76.0` and each
under the 50-word changelog budget AGENTS.md sets: one source of truth, one
undo history, undo outside the editors, undo surviving writes made while away,
the bridge removal, version-history batching, large-document responsiveness,
the first-sync retry, and the dropped-upgrade log.

Four claims are deliberately absent because the record does not say whether
main shares the defect -- the slash-command undo trace, the Mermaid diagram's
undo, the trailing-blank write floor, and the lint-marker narrowing. They are
MP-23 to MP-26 in the PR description's manual checklist, to be run against main
before any of them earns a changeset. The restart replay instrumentation is
absent too: it fixes nothing a user can see, and the defect it diagnosed is
ISSUES.md Issue 4, deferred to its own PR.

`markdown-bridge-removed` describes the four dead `bridge:` switches as removed.
That removal is the next commit; the schema keeps them as accepted-but-unread
today, on a justification that does not hold -- the block is a `z.looseObject`
and the published schema sets `additionalProperties: {}`, so deleting them
breaks no existing config.

check-no-major-changeset.sh clean; scripts/aggregate-stable-changelog and
build-slack-release-payload 34/34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`1b3a86bc2` moved the only caller to `fullProjection` when it narrowed
re-projection to the blocks a peer's edit changes, leaving `liveProjection`
exported and unreferenced — the one knip finding on this branch that upstream
did not also report.

The two STOP comments that named it are updated rather than left stale: the
agent-flash hop in TiptapEditor names `fullProjection`, which is what
`flashEntry` actually reads, and the hidden-editor note names the binding's
projection.

Evidence: knip goes 53 → 52 unused exports with no branch-only finding left
(upstream `9ee18edab` reports 56); typecheck and lint pass; app unit is
9,343 / 0 with 6 expected fail and app DOM 6,361 / 0, both unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…em as dead config

`bridge.deferGuard`, `bridge.fixedPoint`, `bridge.preDrain` and
`bridge.lossDetector` switched machinery this branch deleted. They were left in
the schema described as "Deprecated and no longer read. Still accepted so
existing .ok/config.yml files keep validating" -- and that justification does
not hold. `bridge` is a `z.looseObject` and the published JSON schema sets
`additionalProperties: {}`, so an unknown key was always accepted and ignored;
`committed-scope-diagnostics.test.ts` asserts exactly that. Nothing was being
protected by keeping them.

What keeping them did cost: all four stayed registered in `fieldRegistry`, so
they kept appearing in the settings surface and in `ok config` as four
plausible switches for anyone debugging a sync problem, none of which could do
anything.

This repo already has the retirement path -- `REMOVED_KEYS`, which raises a
`REMOVED_KEY` diagnostic naming the key and pointing at `ok config migrate`.
That is how `content.include`, `folders`, `appearance.editorModeDefault` and
`server.host` went. The four now go the same way, each with a redirect saying
what the switch used to gate and what replaced it:

- deferGuard: Y.Text is the only synced replica, so there is no second replica
  to defer a re-derive against.
- fixedPoint: each client derives its document locally, so there is no
  re-derive loop to bound.
- preDrain: a keystroke already lands in the only synced replica.
- lossDetector: persistence still checks every reconciliation for dropped
  content and writes a recovery checkpoint; `lossCapture.enabled` still
  controls the `ok diagnose` ring.

`bridge.backgroundThrottle` and `bridge.flushOnHide` are untouched and still
live.

Tests follow the state rather than pinning the old one: the two sweeps now
assert the deprecated-leaf set is empty and that a config still setting the
keys parses, the eight per-switch schema tests collapse into one that proves
both, and `kill-switch-sweep`'s fail-closed floor drops from 7 declared leaves
to 3, which is what the enumerator now finds (backgroundThrottle, flushOnHide,
lossCapture).

The published artifacts under `packages/cli/dist/` are gitignored build output;
`packages/cli/scripts/build-config-schema.mjs` regenerates them and
`published-schema.test.ts` runs it -- verified that `bridge` there now carries
only the two live switches.

core config 583/583, cli config 103/103, the three affected app integration
files 23/23, typecheck 12/12, biome and oxlint clean apart from upstream's own
`acp/launch.test.ts` useTemplate finding (`4ff5fb440`). `server-factory` passed
twice and failed one case once across three runs of this same code; it is in
the rotating flake set recorded in ISSUES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssume

`createServer() — generated index wiring > a live index conflict blocks
regeneration until the conflict is resolved` ran under vitest's default 5s
testTimeout while its own body allows four sequential `vi.waitFor` blocks of
20s each. Alone it needs 5.85s -- boot, two index levels, conflict detection,
resolution and a third write -- so it timed out; inside a full-file run it
sometimes came in under 5s on warm state and passed. Measured across four runs
of the same tree: two file-level passes, two failures, and 3 of 3 failures when
run alone with `-t`.

Now carries `}, 30_000)`, which is what 17 other tests in this file already do
and the coherent envelope for a 20s internal wait. Nothing else changed: no
setup was added, because the test is self-sufficient -- with
`--testTimeout=60000` and no other edit it passes alone.

**Pre-existing and independent of this branch.** Verified by restoring
`102fe795b^`'s six config files into the worktree and running the same isolated
invocation: still timed out, at 170 tests rather than 174, the count difference
confirming the revert took effect. So it is neither the single-CRDT cutover's
nor the bridge-switch retirement's. It is also more specific than the rotating
load flake set in ISSUES.md, since it reproduces deterministically in
isolation; this commit can be lifted out of the branch on its own if the
maintainer would rather take it as its own PR.

Isolated 3/3 pass; the whole file 174/174 in 188s. biome and oxlint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ok config migrate` wrote back `bridge: {}` after stripping the four retired
bridge switches. buildClearPatch sets each leaf to null and applyPatchToDocument
deletes it, but nothing removed the mapping the last delete emptied, so a
hand-written config kept a dead stanza. It validated clean, which is why MP-09
passed without noticing.

Deleting a key now walks up from its parent, removing each ancestor the delete
left empty and stopping at the first one that still holds something. An explicit
`{}` in a patch was already a no-op -- Object.entries on it yields nothing -- so
no caller loses a deliberate empty map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manual pass reported a peer's spaces vanishing during same-paragraph
co-editing, which would be byte loss in the path this branch is built on. It is
not: an unanchored space at the end of a line is dropped when the caret moves
away, and from the other client that reads as the peer's space disappearing.

peer-same-line-coedit.e2e.ts could not have caught either way -- it types only
A and B, never a space. Three cases pin the distinction: space-separated words
typed simultaneously at the paragraph end keep every interior space, so do
simultaneous space runs anchored by a marker, and unanchored trailing spaces are
dropped on caret move with both clients converged.

Uppercase markers because the seed text supplies lowercase a and b: counting a
over the whole document scores "Target" and every "block".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The leaves-the-workspace arrow is a `::after` on whatever carries
`data-resolution-state="external"`, and for a link that carrier is the
resolution decoration, which renders as one span per fragment of the
decorated range. The suppression rule assumed those fragments are
immediately adjacent siblings, so `:has(+ …)` could keep the cue on the
final one alone.

A widget decoration inside the range breaks that adjacency. This branch
draws peer carets as widgets, so a collaborator standing inside a URL
renders as fragment, caret widget, fragment: neither fragment has an
immediately-following external sibling, both draw the arrow, and the URL
reads `http://www. ↗google.com ↗`. Each further caret in the range adds
another arrow. Nothing is wrong with the bytes, which is why a switch to
Markdown source and back cleared it.

Fragments of one link are not siblings of the next link's fragments:
the link mark renders its own `[data-link]` element around each link,
so the cue can hang off that element and be drawn once however the
inside is fragmented. Widening `+` to `~` would not have been safe —
wiki-link chips are standalone carriers and two external chips in one
paragraph are siblings.

Dropping the old rule also fixes those chips, which it suppressed by
accident: two adjacent external chips lost the first one's arrow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the trailing space

The nine the fold left were written against AGENTS.md's 50-word changelog
budget. The four added after it were not: 71, 64, 52 and 52 words, and the two
worst spent them on mechanism -- the arrow's `:has(+ ...)` suppression rule, why
a husk validates -- which the same rule asks to leave in docs.

same-line-space-coverage goes entirely. Its commit adds one e2e probe and no
product code, and AGENTS.md skips changesets for test-only edits. What it
described was coverage, not a change a reader of the release notes could act on.

The behaviour that probe pins had no entry at all, which is the real gap. An
unanchored trailing space is dropped when the caret moves away, and main's
fragment held it, so MP-15 is a difference a user meets on upgrade --
trailing-space-saves-with-the-next-word says so.

slash-command-undo-clears-its-query stays. The fold withheld it because the
record did not say whether main shared the defect; run against main, main leaves
the literal `/he` in the paragraph, so it is a real difference and not a branch
round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…views

Two user-facing surfaces the cutover moved that the docs still described as
main behaves.

The attachment row promised a size the bare `![[report.pdf]]` form no longer
carries: the client resolves the embed's path but not the size the server used
to add. Rather than drop the promise, the line now says where sizes do appear,
which is the document list.

Real-time collaboration described presence as a header affordance. A
collaborator's caret is now drawn in the text, labelled, and visible across
Markdown source and the visual editor in both directions -- a capability that
did not exist before, so no reader would have gone looking for it.

No changeset: docs-only, and the behaviour behind each line is already carried
by single-source-of-truth and by the PR's own follow-up on embed sizes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ue to its carrier

Two defects this branch introduced, both found by running the full e2e suite
rather than the specs the cue work added.

`.dark [data-presence-badge="agent"][data-presence-mode="editing"]` had become
`.dark .dark .dark [...]`. 3cb8b0e deleted the two `.dark
[data-agent-flash-state=...]` rules above it and took the bodies but not the
selector tokens, so the surviving rule absorbed a leading `.dark` from each.
Space-separated, those are descendant combinators: the rule wanted three nested
`.dark` ancestors, which never occur, so an agent presence badge in dark mode
kept the light-mode breathing animation. A specificity hack would have been
written `.dark.dark.dark`; nothing here wanted one. Nothing pinned it --
`agent-breathing-dark` appears nowhere outside the stylesheet.

find-replace.e2e.ts is upstream's, added by 3351618 to pin upstream's own fix
for the duplicated cue. It reads `::after` off the `[data-resolution-state]`
decoration fragments, which is where the cue lived until 3b9c484 moved it to
the `[data-link]` element. The test failed 3 of 3 isolated runs and would have
shipped red. It now resolves each fragment's carrier as `closest('[data-link]')
?? fragment` and counts carriers that render a cue, so a link mark's fragments
share one carrier and a standalone wiki-link chip stays its own -- the
distinction that ruled out widening `+` to `~`. The "last fragment carries it"
assertion goes: with the cue on the link element there is no last fragment to
read, and `cueCount === 1` is the property the test was protecting.

No changeset. Both are round trips against this branch's own work, and
AGENTS.md keeps round trips out of the changelog.

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

CLAassistant commented Sep 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

Copy link
Copy Markdown

Thanks for the contribution!

What happens next:

  • A maintainer will review your PR.
  • If you don't hear back within a few business days, please comment here to nudge our team.
  • This repository is maintained through an internal mirror. When your change is accepted, this PR will close automatically. Don't be alarmed when it closes — that's how it merges, and your authorship is preserved.

@miles-kt-inkeep

Copy link
Copy Markdown
Contributor

Hey @jorgens, thanks for the pr I will take a look.

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.

3 participants