Real root cause of the silent MCP edit loss: the web editor always opens the
collaboration document by the page UUID (`page.${page.id}`), but the MCP
opened it by the agent-supplied id — usually a slugId — so `page.${pageId}`
became `page.<slugId>`. For one DB page that is TWO independent Yjs documents;
both persist to the same `pages` row (findById/updatePage resolve id or
slugId), so the human tab's debounced store overwrites the agent edit
(last-store-wins) — gone after reload, never shown live. The slugId doc also
made the server's transclusion sync + embedding reindex throw Postgres 22P02.
Fix:
- MCP (primary): resolvePageId(pageId) returns the canonical UUID — a UUID
short-circuits with no network call, a slugId resolves once via getPageRaw
and is cached both ways. Every collab-write path (mutatePageContent /
updatePageContentRealtime / replacePageContent and the mutate/replace/
unlocked seams) now opens by the resolved UUID, so the MCP and the editor
share ONE Yjs doc. replaceImage's whole-operation page lock also keys on the
UUID so it serializes against the other (now-UUID-keyed) writes.
- Server (defense + kills the 22P02 noise): onStoreDocument passes the resolved
page.id — not the raw doc-name id — to syncTransclusion, the embedding queue,
the mention-notification job, addContributors, and the in-tx history read.
Content store and the empty-guard are untouched.
Tests: a new MCP test stands up a real Hocuspocus server and asserts a slugId
input opens `page.<uuid>` (never `page.<slugId>`), with UUID short-circuit and
single-resolve caching; the server spec asserts the side-effects receive the
UUID for a `page.<slugId>` doc. closes#260
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approve-with-comments auto-review (8 axes); no blockers. Closes the two flagged
test gaps; the two forward-looking dedup suggestions (reconcileHasChildren helper;
unifying reconcileChildren/mergeRootTrees) are non-blocking architecture notes and
left for a follow-up (as with #186's forward-looking point).
1. Ambiguous-id refusal end-to-end (#159): the patch_node/delete_node guard
`if (replaced/deleted !== 1) return null` was only covered in pieces — the
replaceNodeById/deleteNodeById counts and assertUnambiguousMatch in isolation —
so loosening the guard would not have failed a test. New mock test stands up a
REAL Hocuspocus collab server seeded (via buildYDoc, same docmost extensions)
with a two-blocks-one-id document and drives the real client methods: both must
reject with /ambiguous/ AND never write to collab. Tracked via Hocuspocus
onChange (fires synchronously per update, unlike the debounced onStoreDocument)
so a clobbering write is actually observed — verified the test FAILS when the
guard is loosened to `< 1`.
2. scrollToReference zero-match bail: the branch "non-empty id but querySelectorAll
returns 0 -> matches[index] ?? matches[0] is undefined -> return false" (the real
desync: definition present, inline ref removed from the DOM) was uncovered. Added
a footnote.test.ts case: a definition for 'ghost' with no rendered ref -> false,
no scroll.
Verified: 313 mcp tests + 24 editor-ext footnote tests; prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>