Skip to content

CRDT for documents (5/6): migrate wire protocol to automerge-repo + cross-instance fan-out - #1753

Closed
gearnode wants to merge 28 commits into
automerge/4-api-cleanup-perffrom
automerge/5-repo-protocol-migration
Closed

CRDT for documents (5/6): migrate wire protocol to automerge-repo + cross-instance fan-out#1753
gearnode wants to merge 28 commits into
automerge/4-api-cleanup-perffrom
automerge/5-repo-protocol-migration

Conversation

@gearnode

Copy link
Copy Markdown
Contributor

Part of the split of #1657 ("CRDT for documents") into a review stack. Stack index: 5/6. Stacked on #1752.

What this PR does

The architectural core of the migration: a new Go client/server codec and WebSocket transport for the real automerge-repo wire protocol, a session state machine, gateway wiring (opaque ephemeral fan-out through the collaboration hub, serving documents over the automerge-repo protocol, seeding repo documents from stored content server-side), document-id derivation kept in parity with the JS client, cross-instance ephemeral relay over Postgres NOTIFY, and cursor-based selection presence.

Review lens

This is the "why we're doing this migration" PR and the largest one after stage 2. If it's too much in one pass, it splits cleanly at the "Pin the repo gateway integration contract" commit into protocol/gateway bring-up vs. cross-instance fan-out + selection cursors — flag in review if you'd like it broken up further and I'll split it.

Stack

  1. automerge/1-prototype-bringup (CRDT for documents (1/6): bring up Automerge-backed collaboration (prototype) #1749): bring up Automerge-backed collaboration (prototype)
  2. automerge/2-engine-core-parity (CRDT for documents (2/6): Automerge engine core + full upstream interop parity #1750): Automerge engine core + full upstream interop parity
  3. automerge/3-engine-hardening-refactor (CRDT for documents (3/6): fuzzing, ProseMirror render parity, internal refactor #1751): fuzzing, ProseMirror render parity, internal refactor
  4. automerge/4-api-cleanup-perf (CRDT for documents (4/6): public API cleanup + save/load performance #1752): public API cleanup + save/load performance
  5. → this PR: migrate wire protocol to automerge-repo + cross-instance fan-out
  6. automerge/6-frontend-cutover-legacy-removal: cut the frontend over, remove the legacy protocol

cursoragent and others added 28 commits August 12, 2026 12:06
First step toward letting the Go server and Go agents speak the same
collaboration protocol as the JavaScript automerge-repo client while
keeping our pure-Go CRDT engine as the document authority.

Pin @automerge/automerge-repo at exactly 2.6.0-alpha.3 (it is alpha, so
the pinned version is the contract) and record the protocol under
pkg/automerge/collaboration/PROTOCOL.md: the repo message union, the
ephemeral gossip de-duplication by session and count, the Presence
envelope and its four message types with the heartbeat and TTL defaults,
and the CBOR encoding the payload uses. It also states the layering, so
Automerge sync bytes stay owned by our engine, and what is deferred to
the transport phase (the WebSocket adapter framing and join handshake).

Add a generator that writes byte-exact fixtures using the pinned
packages' own CBOR encoder, and commit fixtures for every presence and
ephemeral message plus a round-trip guard, so the Go codec built next is
validated against real JavaScript output rather than a reading of the
type declarations. cbor-x emits 16-bit map-length prefixes rather than
the canonical short form, which the fixtures capture.

No Go code yet: this establishes the pinned contract and ground-truth
fixtures the implementation phases build against.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Implement the message layer of the automerge-repo protocol in Go so the
server and Go agents can exchange presence and route sync without
re-implementing the CRDT wire format. CBOR is handled by
github.com/fxamacker/cbor/v2 rather than a hand-rolled codec.

The codec uses one strict shared decode mode: indefinite lengths and
CBOR tags are forbidden, duplicate map keys are an error, nesting, map,
array, and payload sizes are bounded, and encoding is deterministic core
CBOR. cbor-x emits valid CBOR our decoder accepts, and our deterministic
output decodes cleanly in cbor-x; byte identity is deliberately not a
goal, semantic interoperability is.

presence.go models the four presence messages inside the __presence
envelope, keeping application values as raw CBOR so a caller decodes them
into its own type, and enforces that each type carries only its
meaningful fields. message.go models the document-scoped repo messages
(sync, request, ephemeral, doc-unavailable) with sync bytes left opaque,
plus the ephemeral session/count de-duplication key.

Tests decode the exact bytes the pinned JavaScript client produces for
every presence type, confirm an ephemeral message carries a presence
payload our presence codec reads, round-trip every repo message, and
assert the decoder rejects duplicate keys and cross-type fields. Two
fuzz targets confirm decoding arbitrary bytes never panics; both are
wired into the fuzz-automerge make target.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Pin @automerge/automerge-repo-network-websocket at 2.6.0-alpha.3 and
implement the Go side of its wire framing so the gateway and Go agents
speak it directly.

Each binary WebSocket frame is one CBOR message encoded with the same
repo helper used for payloads, so the frame codec reuses the strict
shared modes. The handshake is a client join advertising protocol
version "1", answered by a server peer selecting a version or an error
frame before the socket closes; after that both directions exchange the
document messages from the message layer, each as its own frame. There
is no length prefix because the WebSocket message boundary is the
framing, and no leave frame because a disconnect is the socket closing.

transport.go models the join, peer, and error frames plus PeerMetadata,
with a FrameKind peek so a reader routes a frame without decoding it into
the wrong shape. PROTOCOL.md gains the transport section, including the
rule that a repo peer id is peer-chosen and must never be treated as a
user identity; the gateway authenticates the connection out of band.

Tests decode the exact handshake and framed-message bytes the pinned
adapter emits, confirm a framed ephemeral still carries a presence
payload our codec reads, and round-trip and validate the frames. The
legacy encoder.js and WSShared.js in the adapter are unused by the
current adapters and are noted as such.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ServerSession, the transport-agnostic core of the gateway. It
negotiates the join/peer handshake, routes inbound document frames, and
de-duplicates gossiped ephemeral messages, all without I/O or CRDT
state. The server wiring only has to move bytes and act on the returned
decisions: document authority stays with pkg/automerge and peer
authentication stays with the server connection.

Accept turns a client join into a peer reply, or, for an unsupported
protocol version, an accepted=false handshake carrying an error frame to
send before closing; a malformed frame is the only hard error. Receive
classifies a post-handshake frame as sync to feed a SyncState, an
ephemeral to fan out, a doc-unavailable, or an ignored control frame,
and refuses any document frame that arrives before the handshake.

Ephemeral de-duplication keeps the highest count seen per session rather
than a set of every message: the protocol guarantees a session's count
strictly increases, so this is both correct and bounded to one entry per
session. RemotePeerID is exposed but documented as peer-chosen, never an
authenticated identity.

Tests drive the handshake with the real JavaScript join frame, reject an
unsupported version, route a sync frame, and cover ephemeral dedup across
repeats, older counts, and distinct sessions.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ServerConn, the synchronous driver that turns the session state
machine and an Automerge sync state into the server side of one
collaboration connection. It owns no socket and starts no goroutines:
the adapter reads a frame, calls a method, and sends the frames returned,
which keeps the whole loop deterministic and unit-testable and leaves
I/O, rooms, and authentication to the server wiring.

The behaviour follows the repo synchronizer for a peer that is the
document authority, captured in PROTOCOL.md: Start runs the handshake and
then announces by draining the sync state into sync frames; Receive
applies an inbound sync or request with ReceiveMessage and answers with
the generated sync frames, forwards a non-duplicate ephemeral unchanged
for the room to fan out, and ignores duplicates, doc-unavailable, and
control frames. Because the server always holds the document it only ever
emits sync, never request or doc-unavailable. SyncChanged drains sync
frames when the document advances from another source.

The driver depends only on a small SyncSession interface, which
*automerge.SyncState satisfies with no adapter (asserted at compile
time). An integration test drives a real empty client sync state through
the loop and confirms it converges to the server's document and text,
exercising the interop linchpin end to end: repo sync payloads are our
engine's sync messages unchanged.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add a live interop test that proves the Go gateway speaks the real
automerge-repo protocol, not just our reading of it. A Go WebSocket
server backed by ServerConn serves a seeded document, and a real
@automerge/automerge-repo client, driven by a small Node script, finds
the document by URL and prints what it materialized. The test asserts the
client reconstructs the server's text, exercising the handshake, CBOR
framing, and the full sync loop against the actual client.

Node 22 provides a global WebSocket, so the client script needs no extra
dependency. The test is gated on AUTOMERGE_REPO_INTEROP_CLIENT so the
default run does not require Node; the test-automerge-repo-interop make
target sets it, and the conformance package's check script now also
syntax-checks the client.

The test file is named interop_client_test.go rather than with a _js
suffix, which Go would treat as a GOOS=js build constraint and silently
exclude.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ClientConn, the client-side mirror of ServerConn, so Go AI agents
join collaboration as first-class repo peers. Like the server driver it
owns no socket and starts no goroutines: the caller sends the join frame
it returns, then feeds inbound frames and sends what comes back.

Start emits the join. Receive completes the handshake on the peer reply
and emits the client's initial sync, applies inbound sync or request and
emits the resulting sync, de-duplicates and surfaces ephemeral payloads
for the application, and reports error and doc-unavailable frames. The
first outbound message is a request when the local document started empty
and a sync otherwise, matching the repo synchronizer's isNew rule.
SyncChanged pushes local edits and Ephemeral builds a presence or other
gossip frame with the caller's session id and count.

The driver depends only on the SyncSession interface, so *automerge.SyncState
drives it directly. Tests move frames between a ClientConn and a ServerConn
until quiescent and assert convergence both ways: an empty agent learns
the server's document, and an agent that already has content pushes it to
an empty server. Handshake, first-message-is-request, and server-error
paths are covered too.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The repo client picks the automerge:<id> URL it wants, so a gateway
that hard-codes a document id can only answer clients that happen to
agree on it. NewServerConn required a non-empty id and announced the
document proactively in Start, which only works when both peers share
the id ahead of time (the interop test hard-codes both sides).

Add NewAdoptingServerConn: it announces nothing on the handshake and
binds to the id in the client's first sync or request frame, then
answers for that id and rejects any other frame on the connection. A
fixed-id connection now also rejects frames for a foreign document
instead of silently answering with its own id.

This removes the need for a server/frontend id-derivation contract
before the production endpoint can serve arbitrary documents.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Repo-protocol presence and cursors travel as opaque ephemeral frames
rather than the legacy structured presence snapshots, and the hub had
no way to relay an arbitrary frame to a room's other peers.

Give each room peer a dedicated ephemeral channel, separate from the
sync/presence wake channel so a dropped best-effort gossip frame never
coalesces with or masks a sync refresh. BroadcastEphemeral fans a frame
out to every other local peer and skips the originating one; delivery is
best-effort, dropping when a peer's buffer is full since the sender
re-emits its ephemeral state.

Fan-out is scoped to this server instance. Cross-instance gossip needs
the payload carried through realtime.Events, which today only signals
that a document changed; that remains a follow-up.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record how the tested ServerConn/ClientConn drivers slot into the
authenticated production endpoint by reusing the existing collaboration
hub for auth, document authority, persistence, and sync fan-out, plus
the new document-id adoption and ephemeral fan-out primitives.

Spell out the contracts that still need the migrated frontend and a
Postgres-backed integration test before the endpoint is enabled:
cross-instance ephemeral gossip, seeding under the repo protocol, and
how a repo client presents its auth credential on the upgrade.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A remote collaborator's caret published as an integer offset drifts the
moment anyone types before it: every downstream offset shifts by one and
the caret lands on the wrong character. An Automerge cursor is a stable
address that resolves to the same character after arbitrary concurrent
edits, which is exactly what a remote caret needs.

Add TextSelectionValue, the presence-update value for a caret or
selection: the addressed text field plus stable anchor and head cursors
(the bytes from Text.Cursor), with encode, decode, and validation
helpers and a NewTextSelectionPresence constructor. The presence layer
only transports the cursor bytes, so it stays independent of the CRDT
engine; the server and Go agents create and resolve them.

A round-trip test seeds text, publishes a caret through the presence
envelope, inserts characters ahead of it, and shows the same cursor
still resolves to the original character where a stored offset would not.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record that carets and selections travel as a TextSelectionValue of
stable Automerge cursors rather than integer offsets, and why: offsets
drift under concurrent edits while cursors stay anchored.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Browser tabs and Go agents must reference a version's document by the
same automerge-repo id, or their sync and, especially, their ephemeral
gossip do not line up: a peer drops an ephemeral frame whose document id
it does not recognise.

Add a dependency-free base58check codec and the repo document-id helpers:
EncodeDocumentID/DecodeDocumentID round-trip the 16-byte id, ValidateID
checks it, and DeriveDocumentID hashes an arbitrary seed (a version GID)
to a stable id so every peer computes the same one without coordination.
AutomergeURL/ParseAutomergeURL wrap and unwrap the automerge: scheme.

The codec is validated against a genuine @automerge/automerge-repo
document id, which exercises the checksum and alphabet against real
upstream output rather than against itself.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record the decisions the gateway and frontend must share to migrate
document editing to the automerge-repo protocol: version-scoped routing,
deterministic document-id derivation, unchanged cookie/bearer auth, and
server-authoritative seeding, plus how presence, reconnect, and rollout
work.

This is the artifact that unblocks the endpoint: it settles the three
contracts that could not be guessed as production code and points each at
the primitives already built and tested in this package.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The browser must compute the same automerge-repo document id for a
version as a Go agent, or their sync and ephemeral presence do not line
up. Port the base58check codec and derivation from
pkg/automerge/collaboration/documentid.go to TypeScript in @probo/ui and
export it for the console.

The vitest suite decodes a genuine automerge-repo id and, crucially,
asserts deriveDocumentId matches the Go DeriveDocumentID byte-for-byte
for a set of seeds, so the two implementations cannot drift.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract status now that the TypeScript
deriveDocumentId mirror ships and is verified byte-identical to the Go
implementation.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Mount a /repo route beside the custom /sync route so the two collaboration
protocols coexist during migration. It reuses the existing auth and the
hub's room, persistence, and cross-instance refresh, and drives the wire
protocol with the tested adopting ServerConn: the client picks the
document id, the server answers for it. Presence and cursors ride repo
ephemeral gossip, fanned out to a room's other peers with the hub's
ephemeral primitives.

Because the repo protocol has no seed handshake, the route refuses an
unseeded document rather than serve an empty one as authoritative;
server-side seeding is a separate contract item.

The loop is a standalone function so it can be driven end-to-end in Go by
a real ClientConn with no database: one test converges a client on the
seeded document, another gossips an ephemeral between two clients through
the hub. Both pass under the race detector.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract status: the /repo route is wired and driven
end-to-end in Go, with Postgres integration, live JS interop, and
server-side seeding still outstanding.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Seeding a collaboration document server-side needs the forward direction
of the ProseMirror bridge, which existed only in TypeScript. Add ToSpans,
the inverse of Render: it walks a ProseMirror document into the rich-text
spans Text.UpdateSpans writes, mirroring how @automerge/prosemirror
flattens the tree.

Every block node emits a marker carrying its full ancestor path of
Automerge block types; list wrappers are transparent and only their items
become blocks; marks map through the shared schema ledger. A container's
first paragraph folds into the container's own content the same way the
renderer reconstructs it: a list item always folds its first paragraph
because the renderer always re-synthesizes one, while a blockquote or
table cell only folds a non-empty first paragraph (or a sole child), so an
empty first paragraph with siblings survives.

The gate is a round trip over the shared 283-document corpus: converting
the canonical ProseMirror JSON to spans, writing them into a fresh
document, and rendering it back reproduces the input exactly, which is the
property server-side seeding relies on.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The repo protocol has no seed handshake, so the /repo route previously
refused an unseeded document. Seed it server-side instead: the connection
that claims the seed converts the version's stored ProseMirror content to
spans with prosemirror.ToSpans, writes them into the shared document, and
persists. Persisting marks the state seeded, so later connections skip
seeding and just sync.

An in-Go test drives a real ClientConn against the loop for an unseeded
version and confirms the client materializes the seeded content, covering
the whole server-authoritative seeding path without a database.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract: ProseMirror to spans conversion and
server-authoritative seeding now ship, leaving the Postgres seed-lifecycle
integration test as the remaining seeding work.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Presence and cursors are relayed between server instances over the
collaboration NOTIFY channel, which until now carried only a bare
document-version id meaning "this document changed". Add a typed envelope
that coexists with that signal: a bare id is never valid JSON, so the two
payload kinds never collide.

The envelope carries the version id, the publishing instance id (so a
server can ignore its own echo), and the opaque frame. Encoding rejects a
payload over a NOTIFY-safe size so an oversized frame falls back to
local-only fan-out rather than failing the notify.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add NotifyCollaborationEphemeral: it encodes an opaque repo gossip frame
into the cross-instance envelope and fires it over the collaboration
NOTIFY channel, stamped with the publishing instance id. It is
fire-and-forget, touches no document, and runs outside any transaction;
an oversized frame returns an error so the caller keeps local fan-out.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Give the hub a per-instance id and teach notifyExternal to tell the two
NOTIFY payload kinds apart: a bare version id still wakes peers to
refresh, while an ephemeral envelope is fanned out to the room's local
peers. A server ignores its own echo, since it already delivered the
frame directly when it published it.

The /repo loop now relays each gossiped frame to other instances through
a publisher hook wired to NotifyCollaborationEphemeral, in addition to
the existing local BroadcastEphemeral. A relay failure or oversized frame
is logged and skipped rather than dropping the connection.

Tests cover the hub delivering an external frame while suppressing its own
echo, and the loop handing each gossiped frame to the publisher.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract and protocol notes: repo presence and cursors
now propagate between server instances over the NOTIFY channel, leaving
only the Postgres delivery path to integration-test.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Repo presence carries a collaborator's caret as stable Automerge cursors
rather than integer offsets, so a remote caret stays on the right
character while other people type. Add the browser helper that builds a
selection from the editor's caret offsets and resolves a selection back to
offsets against the current document, plus a collapsed-caret check.

The shape (field, anchor, head) mirrors the Go TextSelectionValue so both
describe the same thing; the cursor values are the JavaScript Automerge
cursor type exchanged between browser peers.

A vitest suite proves a caret stays anchored across a concurrent insertion
where a stored offset would drift, and that a range selection round-trips.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record that the browser cursor-based selection helper ships and is tested
for cursor stability, leaving the repo NetworkAdapter and live validation
as the remaining frontend work.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The presence decorations work in ProseMirror position space, but stable
cursors live in Automerge text-offset space. Add the bridge in @probo/ui:
presenceFromPmSelection turns a caret or selection into stable cursors to
publish, and pmSelectionFromPresence resolves a remote selection back to
ProseMirror positions against the current document.

The position mapping reuses @automerge/prosemirror's own conversion
helpers so it stays consistent with the editor binding. A vitest suite
builds the real schema adapter and a document and checks a caret
round-trips, a remote caret stays anchored across a concurrent insertion,
and a selection range preserves its endpoints.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
@gearnode

Copy link
Copy Markdown
Contributor Author

Closing in favor of a cleaner 2-PR split: #1757 (Automerge CRDT engine) → #1758 (wiring it into the document editor), built as clean commits from final-state content rather than replayed history.

@gearnode gearnode closed this Aug 21, 2026
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.

2 participants