feat(editor): add real-time collaborative editing - #305
Draft
DavidBabinec wants to merge 51 commits into
Draft
Conversation
…ion banner
Two admins editing the same site could silently overwrite each other:
saves were last-writer-wins per row with no detection. This is level A of
the multi-admin live-sync plan — conflict SAFETY on top of the
transactional save.
Server:
- The incremental save body gains `baseSeqs` (rowId → last-synchronized
seq, covering changed AND deleted rows) and `shellBaseSeq`. Inside the
save transaction — after allocateSiteSeq, whose counter-row lock
serializes concurrent saves, making the check exact — any shipped row
stored NEWER than its base (or with no base entry at all) throws
SaveConflictError, rolling everything back into a 409 with
`{ conflicts: [{ table, rowId, seq }] }`. Nothing is written.
Soft-deleted rows are visible to the check (a remote delete conflicts
with a stale edit). Replace-mode saves skip it (imports bulldoze by
design).
- The shell write + seq stamp now happen ONLY when shell content actually
changed (shellsEqual) — an unconditional stamp would 409 every
concurrent save pair on the always-shipped shell.
- DataRow exposes `seq` (optional in the schema for old bundle archives);
GET /site returns the shell seq; the three collection GETs accept
`?id=<rowId>` for the banner's single-row "Load theirs" fetch.
Client:
- The editor store tracks baseSeqs/shellBaseSeq (seeded at load, bumped to
the response seq on every successful save — deleted rows KEEP entries so
undo-resurrection carries the right base) and the pending saveConflicts
list. Autosave is suppressed while conflicts pend.
- SaveConflictBanner (lazy-mounted in AdminCanvasLayout) offers per-target
resolution: Load theirs (swap the remote version in without undo
history, clear the target's dirty marks, clear undo history, propagate
VC slot re-sync / ref cascades into consumer pages WITH dirty marks) or
Keep mine (bump the base seq — the overwrite becomes a stated decision).
Resolving the last conflict flushes the surviving edits.
- The Spotlight Save command now routes through the editor save queue
(flushEditorSave) instead of a raw replace-mode adapter save that
bypassed the single-flight queue and dirty tracking.
- Settings modal + onboarding shell saves carry their own shell base seq.
Known v1 blind spot (documented): writers outside the transactional save
(plugin pack installs, data-workspace edits) don't stamp seqs and
coordinate via requestCmsSiteReload() instead — widening stamping is
phase B scope.
Co-Authored-By: Claude Fable 5 <[email protected]>
…ditors
Level B of the multi-admin live-sync plan, on top of the conflict-safety
substrate: two admins in the editor now SEE each other's saves land,
instead of silently diverging until a 409.
Server:
- server/events/siteEvents.ts — in-process bus over Bun's native pub/sub
(correct for the single-process-by-definition deployment; multi-process
is documented out of scope). The transactional save publishes post-commit
hints: rows-changed / rows-deleted / shell-changed, ONE site-reloaded for
replace-mode saves, and the publish flow emits `published`. Events carry
ids + seqs, never payloads (@core/persistence/syncEvents).
- server/events/siteSocket.ts — GET /admin/api/cms/site-socket upgrades at
the Bun.serve boundary (the router stays request/response). Gated by
`site.read` AND originAllowed (browsers always send Origin on WS
handshakes — closes cross-origin WebSocket hijacking). On
`{ kind: 'subscribe', cursor }` the server replies with the missed delta
synthesized from `rows where seq > cursor` (soft-deleted included), so
live events are hints and the delta is truth — dropped frames can never
cause drift.
- Vite dev proxy forwards the upgrade (ws: true).
Client:
- useSiteSocket (AdminCanvasLayout) lazy-imports the transport
(siteSocketClient.ts, backoff reconnect, strict in-order processing) so
the sync machinery stays out of the route-shell chunk.
- siteSyncMerge.ts — the merge policy: base seq ≥ event seq → skip (own
echo); dirty target → pending conflict (the SAME banner as a 409, one
resolution UX for levels A+B; dirtiness re-checked after the fetch);
clean → fetch + applyRemoteSnapshot. Aligned deletions (both sides
deleted the row) merge silently. Undo history resets ONLY when remote
content genuinely replaced local state — a toast explains exactly then;
a remote deletion of the open page navigates home with a toast.
- applyRemoteSnapshot (generalized from the banner's Load-theirs) now
deep-equal-skips identical content, so own-save echoes never clear
history; DirtyMarks gains a `shell` flag so remote shell changes apply
silently when the local shell is untouched.
- Fixed a latent phase A hole: the editor bumps site.updatedAt on EVERY
mutation, which would have made every save look like a shell change.
shellsEqual (now shared in @core/persistence/shellsEqual) and the dirty
tracker deliberately ignore updatedAt — the shell seq stays an honest
conflict signal.
Tests: WS integration (real Bun.serve round-trip: gating, delta, live
fan-out), save-emission shapes, delta computation, and 12 merge-policy
scenarios via an injected snapshot fetcher.
Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
…rity reconcile Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Patches identify targets; values write from the post-mutation site (idempotent final-state writes). Inline-text props splice Y.Text, children arrays splice via prefix/suffix diff, rosters derive from pre/post id-set diffs — the granular-merge substrate for co-editing. Co-Authored-By: Claude Fable 5 <[email protected]>
…ects) Co-Authored-By: Claude Fable 5 <[email protected]>
…l persistence, reset protocol The relay hydrates/seeds one Y.Doc per collab document, fans updates out, and persists both the CRDT blob and derived JSON (publisher untouched). Out-of-relay row/shell writes reset affected docs via the new rowWriteEvents seam; the transactional save notifies post-commit. Co-Authored-By: Claude Fable 5 <[email protected]>
…t live pull One WebSocket multiplexes every collab doc (binary frames: docId + type + y-protocols payload) with a site-wide awareness channel and reset broadcasts. Upgrade gating unchanged (session + site.read + originAllowed); write capability resolved once at upgrade — update frames from read-only connections are dropped. Deletes the phase-B hint-event stack (events bus, delta computation, syncEvents schemas, client transport + merge policy) — CRDT sync subsumes it. Co-Authored-By: Claude Fable 5 <[email protected]>
…connect Co-Authored-By: Claude Fable 5 <[email protected]>
…live persistence via relay Store rewiring (Tasks 9-12 of the collab plan): - collabBinding.ts glues the editor store to the Y docs: local mutation patches translate into Y ops (LOCAL_ORIGIN), remote/undo changes project back; per-doc Y.UndoManager with coalesceKey-compatible capture breaks; multi-doc undo groups (one Cmd+Z reverts convert-to-component & friends across page + site + component docs); detached-mode alignment guard rebuilds docs when tests hand-assemble the store via setState. - runHistoricMutation drops patch-pair history + dirty tracking; undo/redo delegate to the binding; lifecycle actions reset the doc world. - usePersistence slims to HTTP-load-for-first-paint + lazy provider connect + status mapping; autosave, Cmd+S, beforeunload flush, dirty snapshots, baseSeq bookkeeping and the save queue are gone (the relay persists). - Deleted: saveTrackingSlice, dirtyTracking, conflictActions, SaveConflictBanner, remoteSnapshot, editorSaveRef, editor.save command + keybinding, EDITOR_SAVE_REQUEST_EVENT, cms loadSiteShell/loadSiteRow and the ?id= single-row filter. - Publish flushes the collab relay server-side (flushCollabDocs plumb) so the baked snapshot includes edits inside the debounce window. - applyChildrenDiff falls back to a wholesale rewrite when the Y array drifted from the pre-state instead of throwing mid-transaction. Co-Authored-By: Claude Fable 5 <[email protected]>
awarenessState.ts publishes one typed presence per editor (identity with a deterministic HSL color, active doc, selection, inline-edit flag, throttled pointer) and validates every peer state with TypeBox at the wire boundary. PeerPresenceOverlay renders peers' selection rings, name tags (✎ while inline-editing) and pointer dots per breakpoint frame, reusing the selection overlay's measure-session + RAF architecture and the --peer-color inline custom-property pattern. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Real Bun.serve running the relay + socket layer, exercised by the actual client provider over real WebSockets: concurrent different-node edits converge on both peers; the relay persists the blob and derived row JSON after its debounce; read-only connections' update frames are dropped; out-of-relay row writes trigger the reset protocol and the rebind carries the external change; a disconnected client catches up via state vectors on its provider's own reconnect. Co-Authored-By: Claude Fable 5 <[email protected]>
- site-shell.md: the save endpoint is reframed as the standalone HTTP writers' path; the level-B live-pull section is replaced by the CRDT co-editing architecture (doc model, write path, relay, wire, presence). - server.md: boot sequence shows the relay + socket layer; repositories table gains collabDocuments + rowWriteEvents. - architecture.md: events row → real-time co-editing row. - editor-history.md: rewritten for per-doc Y.UndoManager history. - capabilities.md: known v1 coarse-write-gate regression note for the relay path. - CLAUDE.md: co-editing stack bullet. - Retired the auto-save preferences (catalog entries, helpers, settings toggles) — the relay persists continuously, nothing consumed them. Co-Authored-By: Claude Fable 5 <[email protected]>
A page/component/layout created by a PEER arrives as a roster entry whose row doc this client never bound — the site projection used to skip it, so the new row never appeared until a reload (caught in the live two-tab smoke). The roster assembly now binds missing row docs through the provider; once their first sync lands, the site re-projects and the row appears. Co-Authored-By: Claude Fable 5 <[email protected]>
Two presence gaps caught in live two-account testing: 1. The local-pointer publisher attached its mousemove listener to the canvas iframe's document ONCE at mount — but frames boot via srcDoc, which replaces the initial about:blank document on load, so the listener sat on a dead document and cursor positions never published. The publisher now attaches to the current document and re-attaches on every iframe load. Verified end-to-end: a real browser's mousemove now reaches a second WebSocket peer. 2. Presence was only visible when a peer SELECTED something — no ambient indication of who's connected. Added PeerAvatarStack to the editor toolbar (one identity-colored avatar per admin, deduped across their tabs, dimmed with a tooltip hint when they're viewing another page) via a new useSitePeers hook, and put the peer's name on the cursor dot (Figma-style) so a moving cursor identifies its owner without a selection. Co-Authored-By: Claude Fable 5 <[email protected]>
…eer stack Root cause of "I don't see the cursor": the overlay positioning helpers hide elements with INLINE display:none and show them by CLEARING the inline value — but PeerPresenceOverlay.module.css declared display:none as the stylesheet DEFAULT for .ring/.nameTag/.pointer, so every cleared element fell back to hidden forever. All peer canvas chrome (selection rings, name tags, cursors) rendered in the DOM but never painted. Removed the defaults (presence chrome starts visible; the first RAF tick places or hides it — the same contract the selection overlay uses) and added a source-scan regression test. Verified visually end-to-end: a second WebSocket peer's animated cursor + name chip now paints on the canvas. Avatar consistency: presence identity now carries avatarUrl + gravatarHash, and PeerAvatarStack renders the shared UserAvatar (upload → Gravatar → initials, same precedence as every other admin surface) inside an identity-color ring that matches the peer's canvas ring and cursor label. Co-Authored-By: Claude Fable 5 <[email protected]>
The rendered cursor no longer snaps to each received sample — the RAF tick keeps a per-peer animated position that eases toward the last target with a frame-rate-independent exponential (tau 90ms; snap on oversized jumps and re-entries, per-frame state dropped when a cursor leaves the frame). Motion is glassy regardless of network cadence, which pays for itself on the wire: the publisher drops from 15 Hz to 10 Hz, skips sub-2px tremor (deadband), and adds a trailing flush so the final resting position always ships despite the throttle. Verified live: ~130 unique rendered positions per 1.5s from ~5 published targets — a continuous glide between sparse samples. Co-Authored-By: Claude Fable 5 <[email protected]>
- The peer cursor is now the editor's own pixel-art cursor glyph (CursorMinimalSolidIcon) tinted with the identity color, hotspot-offset so the arrow tip lands on the published coordinate. - Name tag and cursor label share ONE chip recipe: inline-flex centering, line-height 1, symmetric padding, flex-gap ✎ — consistent spacing on both badges. - Peer selection rings lose their border radius — they trace the selected element's box exactly, like the local selection chrome. - The invisible-chrome regression gate now strips CSS comments before scanning (it caught its own documentation). Co-Authored-By: Claude Fable 5 <[email protected]>
The name tag on top of a peer's selection ring now uses the design system's notch shape (same radial-gradient concave-corner recipe as CanvasNotch): bigger rounding on the two top corners, a square bottom-left corner flush with the ring's corner, and an inverse bottom-right corner flaring into the ring's top edge. The ✎ editing marker moves from CSS content into markup (the flare owns ::after now), and the free-floating cursor label picks up the matching bigger radius. Co-Authored-By: Claude Fable 5 <[email protected]>
…tion tag The floating name next to the cursor is gone — the cursor now carries a small identity-ringed avatar (18px, the same look as the toolbar stack), riding just under the arrow tip. Hovering it shows the admin's name through the shared Tooltip primitive (the avatar is the one piece of presence chrome with pointer-events re-enabled). The selection name tag gains a tiny leading avatar (14px, ringless — the tag is already the identity color). Extracted PeerAvatar (@site/collab) as THE presence avatar: shared UserAvatar (upload → Gravatar → initials) inside the identity-color ring, with rest-prop spreading so Tooltip composes onto it. PeerAvatarStack now renders it too and swaps its native title for the same Tooltip. Co-Authored-By: Claude Fable 5 <[email protected]>
- Cursors are now opacity-driven, not mount-driven: the pointer element is always rendered per frame and the RAF tick flips a data-visible attribute, so a cursor entering a frame fades in and one leaving fades out (220ms) instead of popping. A 250ms exit linger holds the frozen position first — skimming a frame edge no longer flickers, and a quick return cancels the fade and glides from where it stopped. The hover hit-target is disabled while invisible so a faded-out avatar can't ghost-trigger tooltips. The RAF loop now stays alive while any peer is on the doc so fades always complete (still stops when alone). - The cursor avatar hugs the arrow tip (space-3xs/-2xs offsets). - The selection tag padding drops one step (space-3xs / space-2xs). - De-raced the read-only integration test: its happened-after marker now writes a different Y.Map key — asserting on the same key made the test depend on Yjs' concurrent-set clientID tiebreak, which is random per run (it started failing legitimately, unrelated to this change). Co-Authored-By: Claude Fable 5 <[email protected]>
…y presence Relay writes now obey the SAME structure/content/style rules as the HTTP save. Full site-writers skip validation (the common fast path); every update frame from a PARTIAL writer runs through the new update guard: fork the authoritative doc, apply the update to the fork, project both sides back to JSON, and reuse validateSiteWriteDiff/validatePageWriteDiff — component/layout changes and roster changes are structural wholesale, and both non-step1 sync message types are guarded so a hand-crafted step2 can't smuggle an update past the check. A rejected update never touches the authoritative doc; the sender receives a TARGETED reset frame that makes its client reseed from the server, reverting the forbidden change everywhere including its own screen. Read-only connections' awareness frames now relay — presence is not a doc write, so viewers appear in the avatar stack and presence chrome. Closes the documented v1 coarse-write-gate regression (capabilities.md, site-shell.md updated). Integration-tested end-to-end: content-only editor's text edit applies, their structural insert is rejected + reset, and a viewer's presence reaches other peers. Co-Authored-By: Claude Fable 5 <[email protected]>
During an inline text-edit session, presence now carries the caret and selection as Y.Text RELATIVE positions (collab/caretPositions.ts) — pinned to the CRDT items around the caret, so peers resolve the exact spot even while concurrent edits shift the surrounding text (a plain character index would drift; unit-tested against a concurrent insert). Capture: the presence overlay listens to selectionchange on its frame's iframe document while that frame owns the inline session, converts the DOM selection to text offsets, encodes them against the live collab doc, and publishes via a dedicated awareness field. Render: the RAF tick resolves each peer's relative positions to current offsets, maps them to DOM ranges inside the rendered node, and paints a blinking identity-colored caret line plus 25%-opacity selection highlight rects — riding the same measure-session/iframe-coordinate machinery as the rest of the presence chrome. Verified live end-to-end: a peer's published range renders as a highlighted span + caret at the right characters, and a real DOM selection during a session publishes an encoded caret. Co-Authored-By: Claude Fable 5 <[email protected]>
…lisher hooks Security + maintenance pass over the co-editing foundation: - Move validateSiteWriteDiff/validatePageWriteDiff out of server/handlers/cms/ into a new server/writePolicy/ module — they are pure per-capability policy shared by BOTH write transports (HTTP save handler and the collab relay's update guard), not handler code. - Harden server/collab/socket.ts: per-frame payload caps (64 KB awareness, 4 MB sync, transport maxPayloadLength) and awareness identity enforcement — frames claiming another user's identity are decoded, checked against the session, and dropped, so presence can't be spoofed. - Extract the per-frame pointer + caret presence publishers from PeerPresenceOverlay into @site/collab/framePresencePublishers with a shared attach-across-iframe-loads helper; the overlay is render-only (537 → ~400 lines). - Relay integration test: viewer presence resolves the real user id from the db and asserts spoofed states never relay. - Docs track the moves (site-shell.md, server.md, deepEqual header). Co-Authored-By: Claude Fable 5 <[email protected]>
Critical: - collabBinding: the DocSet.ensure path adopted a provider doc with a synced:false gate but never wired whenSynced, so a locally-created doc froze the gate forever and applyLocalSitePatches then rejected every subsequent local edit. Route through bindDocThroughProvider. - integrity: orphan healing appended every unclaimed node flat under root, double-parenting the descendants of a multi-node orphaned subtree (they landed in both root.children and their orphan parent). Re-attach only orphan SUBTREE ROOTS and heal-walk into them; break orphan-only cycles deterministically by lowest id. - relay: persistDerivedJson INSERTed via createDataRow when getDataRow returned null, but getDataRow filters soft-deleted rows — an undo-of-delete hit a permanent primary-key conflict. New upsertDataRowDraft repo helper resurrects the soft-deleted row in place instead. - socket: the async message handler had no try/catch, so a malformed frame or a projection crash in the guard escaped as an unhandled rejection. Wrap it; a failed sync-write frame now sends a targeted FRAME_RESET. High: - socket: full-identity presence check (id + name + avatar + gravatar), not id alone — a peer could keep its own id but paint another admin's name/avatar. Null (clear) states are gated to the connection's own clientIDs so a peer can't erase others' presence. Boundary now validated with TypeBox, not an `as` cast. - server: SIGTERM/SIGINT flush the relay before exit (relay.destroy) so a redeploy mid-debounce no longer drops un-persisted edits. - collabProvider: unbind settles whenSynced so continuations chained on a never-synced doc (FRAME_RESET) don't hang forever. - socket: claim boundDocs synchronously before awaiting retain so two concurrent frames for one unbound doc can't double-increment refs. - usePersistence: only connect the provider when the store holds a site (no zero-doc reconnect loop on hard load failure); a live connection now supersedes a stale load error in saveStatus instead of showing a permanent "Sync failed" over working sync. Tests: orphan-subtree + cycle reconcile, whenSynced-settles-on-unbind, upsertDataRowDraft resurrect, full-identity presence spoof rejection. Co-Authored-By: Claude Fable 5 <[email protected]>
The relay persists on an ~800 ms debounce, so any path that reads rows to publish can bake stale content. Only the full-publish HTTP route flushed (via runtime-threaded flushCollabDocs); per-row publish, the scheduled tick, and plugin-host publish did not. Replace the bolted-on runtime plumbing with one publish-flush seam (server/publish/publishFlush.ts): the relay registers its flushAll at boot, and publishDraftSite + publishDataRow both await runPublishFlush() before reading. Headless MCP reads flush too (cheap — a clean doc's flush is a no-op). Removes flushCollabDocs from the runtime/router/handler options. Client: PublishButton is disabled until the client is synced — offline edits live only in the client's Y docs, and the server flush can't bake what it never received. The existing status chip states the reason inline. Co-Authored-By: Claude Fable 5 <[email protected]>
… sweeps Two safe scaling wins from the review: - usePeerPresences bailed on no awareness change: a peer moving their pointer on ANY doc re-rendered every overlay. Add the useSitePeers-style structural key so a change on another doc is a no-op here; same-doc pointer moves still re-render (the canvas RAF reads pointer positions from the array). - The relay's roster-deletion sweep ran three full-table scans on every site-doc persist, including shell-field-only edits. Skip it when the roster key is unchanged since the last sweep; cleared on site-doc reset so an out-of-relay change still reconciles. The three architectural scaling boundaries (guard fork+project per keystroke, connect-binds-all-docs, whole-row reproject on remote edits) are documented in the design doc as tracked follow-ups — each needs before/after measurement rather than a blind rewrite of a live-tested hot path. Co-Authored-By: Claude Fable 5 <[email protected]>
Cleanliness pass from the review:
- pageDiff.ts dropped its private byte-identical deepEqual for the shared
@core/utils/deepEqual its sibling siteDiff.ts already imports.
- usePersistence dropped its private errorMessage for the canonical
getErrorMessage.
- collabBinding + applyPatches build doc-ids through encodeCollabDocId /
SITE_DOC_ID instead of `page:${id}` / 'site:default' string literals.
- SHELL_PER_ENTRY_KEYS (the shell doc's Y-shape) lives once in schema.ts,
imported by seed + applyPatches instead of two copies that must stay in
lockstep.
- applyPatches: removed a literal no-op statement and an if/else with two
byte-identical branches (the preNode lookup + condition were dead).
- Removed dead barrel surface (buildNodeMap/buildPropsMap/projectNodeMap/
inlineTextPropOf were only used intra-module) and the fully-dead
RECONCILE_ORIGIN constant (integrity reconciles run as pure JSON, never a
tagged Y transaction).
- Deleted the stale 'editor.save' spotlight shortcut + fixed the editor
command header (no Save command — the relay persists continuously).
- PeerPresenceOverlay hides caret + selection highlights in the no-iframe
tick branch so a peer's caret doesn't freeze on a frame detach.
Left deliberately: project.ts's Y→object casts (internal reconstruction, not
an untyped external boundary; architecture gates pass), and the vestigial
per-row baseSeqs save protocol (a separate persistence-layer refactor).
Co-Authored-By: Claude Fable 5 <[email protected]>
bagToDeclarations did `Object.entries(bag)` on the rule's `styles` bag, which threw on a non-object bag and took the entire ClassStyleInjector / canvas down with a white screen. A corrupt or legacy rule (bad import, plugin write, older data — e.g. a packageJson-shaped object wrongly stored under a style-rule key) must degrade to emitting nothing, not crash the whole stylesheet. Guard the shared serializer core (base styles, context styles, and inline styles all funnel through it): a non-object bag yields no declarations, every other rule still renders and publishes. Co-Authored-By: Claude Fable 5 <[email protected]>
…tore The site-doc projection spread `projected.shell as Partial<SiteDocument>` into the store unvalidated — an `as` cast at a wire boundary. A malformed styleRules entry in the live doc (a runtime/packageJson-shaped value stored under a rule key) crashed the SelectorsPanel/ClassPicker on `.name` access the moment an element was selected, and the corruption could never self-heal because only the relay's persist path ran validateSite. Run the projection through validateSite exactly like the HTTP load path and the relay's persist path — one validation vocabulary at all three shell boundaries. validateSite is per-entry tolerant (parseStyleRuleRegistry drops malformed rules, keeps the rest), so one corrupt entry degrades to being dropped instead of taking the editor down. An incoherent mid-sync shell skips the projection tick; the next projection re-runs. Test: seeds a shell, injects a packageJson-shaped object under a style-rule key in the Y map, asserts the projection is faithful but validateSite drops exactly the bad entry while the good rule survives. Co-Authored-By: Claude Fable 5 <[email protected]>
The collab projection carried its own hand-rolled selection cleanup — a weaker duplicate of `pruneCanvasSelectionDraft`. It only inspected the `selectedNodeId` anchor, so a multi-selection kept phantom ids for deleted nodes and lost its survivors wholesale when the anchor died; it never closed an inline-edit session whose node had vanished (a peer deleting the node you were typing in left a dangling session); and the site/roster branch pruned nothing at all when a whole page disappeared. The duplicate existed because `initCollabBinding` typed the store as a bare `StoreApi<EditorStore>`, which erases the zustand-mutative augmentation of `setState`. Draft-mutating helpers were therefore uncallable and a subset of one got reimplemented inline as a partial patch. The same missing type had already forced a cast in the plugin runtime. Type the store api once (`EditorStoreApi`, via zustand's `Mutate` with the `zustand/mutative` mutator) and the workarounds delete themselves: - collabBinding: all three projection exits (site/roster, row-removed, row-updated) now call `pruneCanvasSelectionDraft`. One implementation serves local deletes, undo/redo, and remote peer edits alike. - plugins/runtime: both casts and the comment excusing them are gone. Selection is still not undoable — it is reconciled. Documented in docs/reference/editor-history.md, which previously left that contract out. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… racing
The shared ContentPage fetch mock answered every `GET /tables/posts/rows`
with an empty list — even after a `POST` had created `entry_1`. That is not
a shape a real server can produce, and it made the whole file's
create-then-assert tests depend on request ordering.
`useContentWorkspace.loadEntries` treats a list response as authoritative
unless the selected row changed *during* that request. That is correct: a GET
issued after a POST returns the new row. But when scheduling happened to issue
the initial list GET after the create, the mock's bogus empty list wiped the
just-created row, and `findByText('Untitled')` failed. Which request won that
race was luck — main sits on the winning side of it, so the flake never
surfaced there.
Model the endpoint instead: rows created by POST are visible to later GETs,
and a DELETE removes them. Both orderings now hold.
Verified: full suite 5/5 green (previously failed ~40% of runs).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`awarenessUpdateFromSession` returned one boolean for two unrelated outcomes,
so the socket logged every refusal as `dropped awareness frame with spoofed
identity`. Most of those are not attacks: every y-protocols client runs
`checkOutdatedAwarenessStates` and broadcasts a removal for any peer whose
heartbeat it stopped hearing, so clients routinely try to clear clientIDs they
do not own. A live three-admin session produced four of these warnings in a
couple of minutes — a production log that cries wolf teaches operators to
ignore the one warning that matters.
Replace the boolean with a typed `AwarenessRefusal` ('impersonation' |
'foreignClear' | 'malformed'). All three are still refused — a peer must never
erase another's presence for everyone — but only the two that mean something
are logged.
Also gate the two collab behaviours the live smoke exercised and the suite did
not cover at all:
- a peer cannot erase another peer's presence for everyone (the foreignClear
branch had zero coverage; deleting the guard was silent),
- `runPublishFlush` drains the persist debounce, so publish bakes the edit an
admin made seconds earlier instead of losing it to the debounce window.
Both are mutation-checked: removing the guard fails the first, removing the
flush call fails the second.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Mechanical integration of origin/main (0.0.13) with no behavioral change.
Three conflicts, resolved as follows:
- server/db/migrations-{pg,sqlite}.ts — both sides appended an id `021`.
main's `021_mcp_oauth` shipped in 0.0.13, so live installations have
already recorded it; it stays first and byte-identical. This branch's
entry is renumbered to `022_collab_documents` at the same index in both
dialect files, keeping migration-parity.test.ts satisfied.
- docs/features/mcp-connectors.md — main rewrote the document wholesale
(MCP Connectors → MCP connections, hosted OAuth flow). Took main's
version and re-applied this branch's CRDT claim on top: relayed edits
need no post-tool save step, and headless reads/publish flush the relay
server-side rather than waiting on a client-side draft flush.
bun.lock regenerated (sharp ^0.35.0 from main + yjs/y-protocols/lib0 from
this branch); the textual auto-merge is not a valid lockfile for the union.
Verified: migration-parity + cmsMigrations green before committing.
…e ws proxy The co-editing socket never connected under `bun run dev`: the toolbar sat on "Connecting" forever and, since this branch removes every other save path, each keystroke was silently discarded. Reconnect attempts then killed the dev server outright. Root cause is a runtime swap, not the collab code. Until #202 (fdfcb15), scripts/dev.ts launched Vite through `node_modules/.bin/vite`, whose `#!/usr/bin/env node` shebang ran it under Node, and the ws proxy worked — which is why this was hand-verified green on 2026-07-03. #202 introduced `viteCommand()`, which spawns `bun node_modules/vite/bin/vite.js` and bypasses the shebang, so Vite now runs inside Bun. Bun's `node:http` ClientRequest never emits 'upgrade', so the backend's 101 reaches Vite's proxy as an ordinary response and takes the non-upgrade fallback: the browser socket hangs in readyState 0 — never opening, never closing, so the provider's reconnect path is never even reached — and when that connection later ends, the proxy's `socket.destroySoon()` call (absent on Bun's socket) throws uncaught and takes down Vite and the CMS server together. Vite itself was never bumped; 8.0.10 has been pinned since the initial import. Fix: take Vite out of the WebSocket path entirely. - `collabSocketUrl` dials the CMS port directly under `import.meta.env.DEV`, preserving `location.hostname` and swapping only the port. The session cookie is SameSite=Lax and a handshake is not a top-level navigation, so a third-party handshake would drop it and 401 into an endless reconnect — ports don't affect that judgement but hosts do, and dev serves Vite on 127.0.0.1 while developers type localhost. Production stays same-origin. - The port is inlined from the same `process.env.PORT` the proxy target uses, so the two cannot drift, and no operator-facing env var is added. - scripts/dev.ts now passes PORT to the Vite child explicitly; it previously worked only because CMS_PORT's default matched vite.config.ts's. - `ws: true` is removed from the proxy, which structurally disarms the crash. Verified: 101 on the new dev target with a real session cookie and a localhost:5173 Origin; an upgrade against the old proxy path now leaves both dev processes alive; the define folds into the dev module as VITE_CMS_DEV_PORT. socketUrl.test.ts pins both branches, devWorkflow.test.ts gates `ws` forwarding against returning. bun test 6325 pass / 0 fail, bun run build, bun run lint.
… doc
The site editor would not load once the collab socket actually connected:
every projection threw and was skipped, so the store never updated and the
canvas stayed on its loading skeleton.
[collabBinding] projected shell failed validation — projection skipped:
TypeError: Cannot assign to read only property 'tokens' of object
Yjs stores plain JS values BY REFERENCE — `Y.Map.get()` returns the very
object living inside the CRDT — and the projection handed those references
straight out. The editor store and the doc therefore shared mutable state,
which broke in both directions:
- `validateSite` normalizes in place (`normalizeFrameworkColors` assigns
`colors.tokens`, and `parseSiteSettings` passes `framework` through by
reference), so merely PROJECTING a shell rewrote the colour tokens held
inside the doc — an untracked mutation outside any transaction that no
peer ever sees.
- The editor store runs Mutative with `enableAutoFreeze: true`. The first
projection to land in the store deep-froze everything reachable from it,
including the objects the doc still held, so the SECOND projection threw
on assignment. Every projection after that failed identically, which is
why the editor never rendered.
Fixed at the boundary rather than at the crash site: `own()` takes ownership
of every plain value leaving a Y container (`projectSiteDoc` shell fields and
per-entry maps, `projectNodeMap` props and breakpoint overrides, `projectMeta`,
the layout snapshot). Primitives — the overwhelming majority of node props —
pass through untouched, so only genuinely shared object graphs are cloned. Y
types are returned as-is; they only reach `own()` from a malformed doc, and
`structuredClone` would throw on their internals.
Verified: the reproduction test fails 3/3 without the fix and passes with it;
the editor loads a 212 KB page with a full layer tree and a clean console;
site rows and collab blobs are byte-intact. bun test 6328 pass / 0 fail,
bun run build, bun run lint.
…ession The main merge reintroduced, one layer up, the hazard commit 2722c72 fixed: `ruleHasAuthoredDeclarations` read `Object.keys(rule.styles)` and `Object.values(rule.contextStyles)` unguarded. Those bags are the wide persistence type — a corrupt or legacy rule (bad import, plugin write, older data) can carry a non-object bag, and `Object.keys(null)` throws. This runs BEFORE the shared serializer that already guards exactly this (`bagToDeclarations` in @core/publisher/classCss), so one malformed rule threw here and took the whole canvas stylesheet down with it — the white screen that fix was written to prevent. It merged cleanly because the two guards live in different files, which is why the test now pins the canvas layer too. A malformed bag is treated as unauthored: that rule emits no suppression and every other rule still renders.
…ence The Playwright suite was dead against `src/`: `saveDraft` clicked `toolbar-save-draft-action`, a testid this branch removed along with the client save pipeline. 16 spec files imported it, so every test that touched the site editor failed on a 10s locator timeout. There is nothing to replace it with, because there is nothing to replace: edits stream to the relay as they land, publish flushes the relay before it reads rows, and a reload re-syncs from the relay's authoritative doc. The helper and all 40 call sites are simply gone. The guarantees the callers actually depended on still hold, and the tests now prove the CRDT path rather than the deleted one — the three capability personas, for instance, still edit, reload, and assert their change survived, which now exercises the relay's per-category update guard end to end. Also fixed here, because retiring saveDraft is what exposed it: scheduling a publish 404'd with "Data row not found" for any page created in the editor. `handleRowSchedulePost` read the row straight from the DB, but a freshly created page lives in the relay's in-memory doc until the persist debounce elapses. `publishDataRow` already flushes for exactly this reason; scheduling is "publish later" and now does the same. The old saveDraft call had been hiding the race by forcing a write first. PAGE-004 and SAVE-001 retitled — "unsaved draft" is not a state that exists in the site editor any more, and the status chip reads "Draft synced". The Content workspace keeps its own save model, so its `Unsaved draft` assertions are untouched. docs/e2e/feature-validation.tsv updated for both, plus the SITE-017/SITE-018 rows that still pointed at deleted modules (saveTrackingSlice.ts, site/dirtyTracking.ts) and claimed Save Draft persists template state. Verified: page-management 11/11; visual-builder + capabilities + core-owner-lifecycle + preview-live 52 passed with one unrelated pre-existing failure (see below); the new server test fails without the schedule flush and passes with it. bun test 6331 pass / 0 fail, bun run build, bun run lint. NOT fixed here, deliberately: CAP-005 "AI provider manager can access provider/default tabs" fails on origin/main today. Commit 76f15fa "feat(ai): redesign settings and add MCP OAuth (#245)" replaced the AI workspace tablist with a sidebar nav and never updated its specs, and tests/e2e/ai.e2e.ts has the same drift. Different reason to change; it needs its own PR against main.
… race-free
The reset seam is how every out-of-relay write — a Settings save, Super
Import, a plugin pack install, a data-workspace row edit — reaches connected
editors. Three races let a reset silently do nothing, or take live data with
it.
1. `evict({persist:false})` did not await `persistChain`. A persist already
past its `dirty` check would resolve AFTER `deleteCollabDocuments` and
re-insert the blob by upsert, resurrecting the generation the reset had
just deleted — so clients rebound onto stale state and the out-of-relay
write was invisible. `evict` now awaits the chain whether or not it is
persisting, re-checks the timer after the await, and `openDoc` waits on a
per-doc `settling` promise so it can neither hand back a doc that is being
destroyed nor hydrate from a blob the reset is still deleting.
2. `resetDocs` reseeds the site doc's rosters from `listDataRowIdSlugs`, so a
page that existed only inside another doc's persist debounce was absent
from the DB, vanished from the reseeded roster, and would then be
soft-deleted by the next sweep. Dirty docs that are NOT being reset are now
flushed first. The reset docs themselves are still deliberately not
flushed: the out-of-relay write that triggered the reset already committed
and must win.
3. Eviction dropped the ref count, so after a reset `openDoc` restarted at 0
while N connections still listed the doc in `boundDocs` — the first close
drove refs negative and evicted a doc other editors were writing. The
counts are now re-registered, and `retain` no longer asserts on a registry
entry a concurrent reset may have removed (it re-opens until the doc it
returns is the one whose refs it incremented).
Also: `persistDerivedJson` now reports its outcome, and a shell that fails
`validateSite` marks the doc dirty again. The blob was being written while the
derived JSON stayed stale, and a reset reseeds from that JSON — which is how
an accepted page could silently disappear. `incomplete` (a doc not assembled
yet) still does not retry, so there is no spin.
Both new tests fail without this change and pass with it. The first holds a
blob write mid-flight through an injected DbClient gate — the only way to
reproduce the window deterministically rather than hoping a timer lands.
A reset deletes the doc's blob and the relay reseeds it at the FIXED
`SEED_CLIENT_ID`, so generation N+1 occupies the identical
`(client 1, clock 0..K)` coordinates as generation N. FRAME_RESET is a topic
broadcast, so a client that was offline never hears about it — and on plain
reconnect its `syncStep1` makes the server answer with
`encodeStateAsUpdate(doc, staleSV)`, which omits exactly the structs the stale
client "already has" at those colliding coordinates and ships the full delete
set. Both ends then diverge silently, and the divergence persists into
published content. This is reachable today with no new code; it is why this
lands before any resync work.
The frame envelope gains the doc's lineage id:
frame := varString docId | varString generation | varUint frameType | payload
The server refuses a cross-lineage frame BEFORE `readSyncMessage`, before the
write-policy guard, and before any `applyUpdate` — answering a stale step1 is
itself enough to corrupt. `''` means "I hold no server state for this doc":
always fine for a step1, and fine for a write only while the server's doc is
still empty, which is precisely the client-created-row flow that populates a
doc at bind time before any inbound frame. Presence and reset frames carry
`''`.
Generations are minted with `nanoid()` on seed and persisted immediately
rather than through the debounce — a doc that hydrated cleanly is not dirty,
so a mint riding the debounce would never reach the DB and the next open would
mint a different id, resetting every bound client for a byte-identical
lineage. Migration 023 adds the column additively; rows written by 022 carry
`''` and get one minted on their next open.
FRAME_RESET now carries a reason, because the client cannot otherwise tell a
routine reseed from losing its work. `rewritten` (another admin saved a
setting, a plugin installed, the data workspace edited a row) stays silent;
`stale`, `refused` and `oversize` raise a toast naming what happened. A reset
also prunes that doc from the undo routing — its UndoManager is rebuilt empty,
so a stale routing entry made Cmd+Z a silent no-op that consumed a step.
Oversize sync frames stop being dropped with a bare `return`. Silence there
diverges the client permanently: it holds structs the server will never
receive, every later update queues behind them as pending, and the screen
shows edits that can never publish. It now answers `FRAME_RESET('oversize')`.
Also removes `CollabRelay.applyUpdate`, which had no production caller.
Verified: the forged-dead-lineage frame is rejected (test fails when the check
is disabled); codec round-trips generation and reason, degrading an unknown
reason to the routine one rather than throwing on wire data. bun test 6338
pass / 0 fail, bun run build, bun run lint.
…ble" The editor is about to refuse edits it cannot deliver, which makes the definition of "connected" load-bearing. `readyState` cannot carry that weight: on a black-holed path — VPN drop, captive portal, sleep/wake, mobile handover — the browser keeps the socket OPEN until the TCP retransmission timeout, which is minutes. Throughout that window every `send()` succeeds into a buffer nobody drains, the toolbar reads "Draft synced", and the work is already lost. Bun's protocol-level pings cannot help: the browser answers them without telling JavaScript, so they prove nothing to the side that has to decide. A FRAME_PING / FRAME_PONG round trip on a 10s interval bounds that window to ~2.5 intervals. Missing it publishes `offline` SYNCHRONOUSLY rather than waiting for a close that may never arrive — the gate must not keep accepting edits while the chip still claims everything is fine. Pings are answered before the frame reaches `parseCollabDocId` or the relay, and are deliberately ungated: a read-only viewer needs to know its socket is alive exactly as much as a writer does. `canSend()` becomes the single "can this edit reach the relay" predicate, shared by `sendFrame` and (next commit) the write gate, because it is one fact rather than two. It also refuses once `bufferedAmount` exceeds 512 KB, which catches the slower failure the heartbeat misses: the socket is genuinely open but the bytes are not moving. `reconnectNow()` escapes the backoff, which reaches 30s. When the user tells us the network is back, making them watch a timer is the wrong answer. Two honesty fixes that came out of this: - the provider's docstring claimed "no flush window to lose on unload". That is false — `send()` only enqueues, and the browser discards `bufferedAmount` on unload. The comment now says so, and `beforeunload` calls `preventDefault()` while bytes are queued. A large paste is one multi-hundred-KB frame and there is no client-side save path behind it. - the ping interval is injectable, because a test that proves the timeout works should not spend 31 seconds doing it. The suite version runs in 255ms. bun test 6342 pass / 0 fail (twice), bun run build, bun run lint. Unrelated: `contentAdmin.test.tsx` "does not reopen the settings preference when the last selected entry is cleared" failed once during this work and passed in isolation, in both pairwise combinations, and in two consecutive full runs. It is an intermittent timing flake in the Content workspace, not a regression from this change.
…nt edits The client's step1 only ever pulled the SERVER's delta. Nothing pulled the client's, and the server never originated a step1 of its own — so any edit committed locally while the socket was down never reached the relay, even after a clean reconnect. Worse, later edits depend on those structs, so Yjs held them as pending server-side too: the session diverged permanently while the toolbar showed everything synced. The fix is one frame. y-protocols already answers a step1 with exactly the missing delta, and the provider already ships whatever its encoder produced, so originating a step1 from the server needs ZERO client changes. Sending our own state vector on a connection's first sync frame per doc is the whole mechanism. Gated on `fullSiteWriter`, deliberately and unhappily. A partial writer's reply is a single update carrying its entire missing state, and `validateGuardedUpdate` accepts or rejects that as ONE unit — a single forbidden op inside the batch would discard every legitimate edit alongside it. Losing a content-only editor's recovery window is bad; silently destroying the rest of their session while trying to save it is worse. Closing that properly needs a splittable guarded update, which is a follow-up. `probedDocs` is per-connection rather than per-doc, because a reconnect brings a fresh socket and that is precisely when the probe has to run again. This lands AFTER the generation stamp for a reason: asking a client for its state is exactly what makes a missed reset dangerous, and the lineage check is what makes the answer safe to merge. The new test fails without the probe and passes with it — it kills the transport, edits, asserts the relay has NOT seen the change, reconnects, and then asserts the edit reaches both the authoritative doc and `data_rows`. It is the mirror of the existing "a reconnecting client catches up on edits it missed", which only ever covered server → client. bun test 6343 pass / 0 fail, bun run build, bun run lint.
…en reverts The stress test (five concurrent role-scoped editors) surfaced a read-only viewer whose local doc permanently diverged while the toolbar still read "Draft synced". Every other peer had the correct converged state; only the viewer showed its own rejected edits, merged into a text node by the Y.Text CRDT and never healed. Cause: the socket dispatch special-cased read-only connections. A non-step1 frame from a connection lacking every site-write capability was dropped at a bespoke `canWrite` gate (`... && !ws.data.canWrite) return`) with NO reset — so the sender kept its optimistic local change forever. Partial writers never had this problem: their forbidden writes go through the category guard, which sends a targeted reset that reverts the change on their own screen too. A read-only viewer is just a partial writer with zero write capabilities. Remove the special case (and the now-dead `canWrite` field) and let read-only connections ride the same guard path: the guard refuses any real change (→ reset → the viewer's doc reseeds from the server and reverts), while a viewer's empty handshake step2 diffs to nothing and passes as a no-op. One enforcement path, no divergent screens. The existing "drops update frames from a read-only connection" test only checked that other peers never saw the edit — it never pinned the viewer's own divergence, which is why this shipped. Strengthened to assert the viewer receives a reset and its rebound doc no longer holds the edit; mutation-checked (restoring the silent drop fails it). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…user There is no client-side save pipeline behind the relay: an edit that cannot be delivered is an edit that is gone. Until now a local mutation applied to the store optimistically even while the socket was down or a doc had not finished its first sync, so the user kept typing into work the server would never accept and was told nothing — silence was how their edits disappeared. `applyLocalSitePatches` now returns a `LocalPatchOutcome` instead of a bare boolean. When the transport can't send (`transportBlockReason`), the mutation is refused wholesale — `mutateActiveTree` reports it as not-accepted, the node-insert action returns '' rather than a phantom id, and the user gets one toast per block EPISODE (offline / connecting / syncing), not one per keystroke. The first notice of an episode also ends the inline-edit session so the contentEditable snaps back to the last value the relay actually took. Finishing this pushed collabBinding.ts past the 700-line god-file ceiling, so the block/reset policy and its user-facing copy move to a sibling collabNotices.ts (transportBlockReason, collabBlockToast, clearCollabBlockNotice, collabResetToast, the copy maps, and the BlockedReason/LocalPatchOutcome types). collabBinding keeps only the thin notifyCollabBlocked wrapper that pairs the toast with the store side effect. Behavior-preserving: the collab provider and editor-store suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…racters The inline-edit contentEditable was seeded once per session and never received remote Y.Text changes; every keystroke committed the element's WHOLE string through the snapshot diff (applyInlineEditValue → applyTextDiff). With a peer's characters in the doc but absent from the frozen surface, the next local keystroke's diff read them as a local deletion and removed them from the CRDT — every replica converged to text missing the peer's edit. Reproduced end-to-end on a canvas mount: peer typed " world" into "hello", one local "!" keystroke converged BOTH replicas to "hello!". Fix at source: attachInlineEditRemoteMerge observes the edited prop's Y.Text for the session's lifetime and folds every non-local change into the DOM through the same seeding writer, restoring the local caret at an index transformed through the Yjs delta (insert-at-caret pushes right, matching relative-position association). IME composition defers the rewrite to compositionend, with the caret transformed through a synthesized single-splice delta. Wired in NodeRenderer's session layout effect; nodeTextOf deduped out of caretPositions into @core/collab. Mutation check: with the NodeRenderer wiring reverted, both canvas behavioral tests fail (surface frozen at "hello" while the store shows "hello world") and the wiring source gate fails; restored, all green. Verified: bun run build, bun test (6353 pass ×2), bun run lint. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Records the co-typing fix as done, pins what this run verified solid (Y-level merge granularity, canvas co-typing end-to-end, the microtask ordering that protects the store-level diff), and ranks the open findings for the next run — applyTextDiff's missing drift guard first. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
dazzakiller
pushed a commit
to dazzakiller/Instatic
that referenced
this pull request
Jul 29, 2026
…ict resolution Per maintainer guidance (issue #N): check collab PR CoreBunch#305 for conflicts and resolve before opening the visitor-auth PR. PR CoreBunch#305 is based on the same commit (d97cac2) so no rebase skew; 17 files overlapped, only 6 conflicted, all resolved additively (no behaviour change on either side): - migrations: PR CoreBunch#305 adds 022_collab_documents + 023_collab_document_generation; our visitor migrations renumber 022-027 -> 024-029 so both coexist (021 mcp_oauth -> 022/023 collab -> 024-029 visitor). Both dialects. - loops/sources/index.ts: keep both EntryFieldSource (PR CoreBunch#305) + visitor sources. - DataPage.tsx: keep both handleSavePageAccess (ours) + handlePublishData/draft (theirs). - RowDetail.tsx: keep our page-access imports; drop isBuiltInValueLocked (PR CoreBunch#305 renamed the guard; now-unused). - shared.ts: union the doc comments. - bun install: yjs/lib0/y-protocols (collab CRDT deps, already in merged package.json). Verified: tsc -b clean on the merged tree; full suite — ZERO failures in our visitor code; the 9 remaining failures are pre-existing flakes confirmed identical on clean upstream/main (capability route matrix, CMS plugin handlers, circular deps 50s timeout, PP-25/step-up under parallel load). The visitor-auth + per-visitor-data PR can now be opened against a tree that already carries collab — no conflict surprises for the maintainer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why
Concurrent admins and AI/browser actors should merge edits instead of racing whole-document saves. The relay becomes the single authoritative write path for an open Site workspace while existing out-of-relay repository writes explicitly reset affected CRDT documents.
User and developer impact
Verification
Scope boundary
This PR keeps only the MCP consistency seam required by collaboration: pending relay writes flush before headless reads and publish. It intentionally does not upgrade the MCP SDK/protocol or add headless Site mutation tools; those follow after this collaboration foundation lands.