# Protocol

This page is **the spec**. It describes the complete wire protocol between an
editor client and a collaboration server, plus the transformation and
sequencing rules both sides must follow. A reader should be able to implement
their own server (or client) from this page alone.

The reference implementation of everything described here is the
[`@jodit/collab-protocol`](https://www.npmjs.com/package/@jodit/collab-protocol)
package: pure TypeScript, no DOM, no IO. Its zod schemas _are_ the message
contract; its `xform`/`DocumentSession`/`ClientSync` are the reference
algorithms, verified by property-based convergence tests.

Current protocol version: **1** (`PROTOCOL_VERSION`), negotiated in
`hello`/`welcome`.

## Document model

A document is a JSON tree of two node kinds. Every node carries a **stable
id** that never changes for the lifetime of the node:

```json
{
	"id": "root",
	"type": "element",
	"tag": "div",
	"attrs": {},
	"children": [
		{
			"id": "d0",
			"type": "element",
			"tag": "p",
			"attrs": {},
			"children": [{ "id": "d1", "type": "text", "text": "Hello" }]
		}
	]
}
```

Node ids are strings of 1-128 characters. The root element is conventionally
`"root"` and is not removable.

### Tolerance rules

Applying a patch is **total**: it never throws. These rules are part of the
spec; convergence depends on every implementation applying them identically:

- a patch targeting a missing node is a **no-op**;
- an `insert` whose `afterId` cannot be resolved among the parent's current
  children **prepends** the node as the first child (equivalent to
  `afterId: null`);
- an `insert` whose node id already exists anywhere in the tree is a
  **no-op** (idempotency: the same id is never materialized twice);
- a `text` splice is **clamped** to the current text bounds;
- a `remove` of the root node is a **no-op**.

## Patch types

Every document change is a flat list of id-addressed patches. Nodes are
addressed by id, never by path, so concurrent structural edits in different
places commute without index shifting. The **only** index in the whole format
is the character offset inside one text node.

### `insert`

Insert `node` into `parentId` right after sibling `afterId` (`null` = insert
as first child). The node may be a whole serialized subtree.

```json
{
	"op": "insert",
	"parentId": "root",
	"afterId": "d0",
	"node": {
		"id": "n42",
		"type": "element",
		"tag": "p",
		"attrs": {},
		"children": []
	}
}
```

### `remove`

Remove the node (and its whole subtree) by id.

```json
{ "op": "remove", "nodeId": "n42" }
```

### `text` (splice)

Splice inside a text node: delete `remove` characters at `index`, then insert
`insert` there. Both `index` and `remove` are non-negative integers.

```json
{ "op": "text", "nodeId": "d1", "index": 5, "remove": 0, "insert": ", world" }
```

### `attr`

Set (string value) or remove (`null`) one element attribute.

```json
{ "op": "attr", "nodeId": "d0", "name": "style", "value": "color: red" }
```

```json
{ "op": "attr", "nodeId": "d0", "name": "style", "value": null }
```

## Transport

- WebSocket endpoint: `${routePrefix}/ws` (default `/collab/ws`). Upgrade
  requests to any other path are destroyed.
- All messages are JSON text frames.
- Maximum message size: **1 MiB** (larger frames are rejected by the socket
  layer).
- The **first** message on a fresh connection must be `hello`, within
  **10 seconds**. Otherwise the server sends
  `error {code: "auth_required"}` and closes.
- Client→server schemas are **strict**: an unknown key is a validation error
  (`bad_message`). During the handshake (before a successful `hello`) a
  malformed message closes the connection; afterwards it only produces an
  `error` message.

## Client → server messages

There are nine: four document/session messages (`hello`, `submit`,
`presence`, `ping`) and five comment messages (`comment.add`,
`comment.edit`, `comment.reply`, `comment.resolve`, `comment.delete`; see
[Comments](#comments)). Note there is deliberately **no way** for a client
to send a display name or color. Identity travels only server→client. See
[Authentication](authentication.md).

### `hello`

Sent once, immediately after the socket opens. The handshake _is_ the
authentication.

```json
{
	"type": "hello",
	"protocolVersion": 1,
	"docId": "my-document",
	"token": "eyJhbGciOiJIUzI1NiIs...",
	"sinceSeq": 0
}
```

| Field             | Type                        | Notes                                                                                          |
| ----------------- | --------------------------- | ---------------------------------------------------------------------------------------------- |
| `protocolVersion` | positive int                | Must equal the server's version (1), else `protocol_mismatch` and close.                       |
| `docId`           | string 1-256                | Document (room) to join.                                                                       |
| `token`           | string ≤ 8192, optional     | Opaque credential resolved by the server's `checkAuthentication` hook. Omit in anonymous mode. |
| `sinceSeq`        | non-negative int, default 0 | Resume point: the last `seq` this client has already applied. `0` = fresh join.                |

Sending `hello` again after a successful join is a `bad_message` error (the
connection stays open).

### `submit`

One optimistic batch of local edits.

```json
{
	"type": "submit",
	"opId": "p7#3",
	"baseSeq": 41,
	"patches": [
		{ "op": "text", "nodeId": "d1", "index": 5, "remove": 0, "insert": "!" }
	]
}
```

| Field     | Type                | Notes                                                                                                      |
| --------- | ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `opId`    | string 1-64         | Client-generated id, echoed back on the author's copy of the broadcast; that is the ack.                   |
| `baseSeq` | non-negative int    | The last server `seq` the batch is based on (everything the client had applied when it created the batch). |
| `patches` | array, 1-1024 items | The batch, in order.                                                                                       |

### `presence`

Ephemeral caret/selection update; not persisted, not sequenced.

```json
{ "type": "presence", "selection": { "nodeId": "d1", "offset": 5 } }
```

`selection` may be `null` (caret left the document).

### `ping`

```json
{ "type": "ping" }
```

Answered with `pong`. The reference client sends one every 30 s to keep
idle-closing proxies from dropping the socket.

## Server → client messages

### `welcome`

The reply to a successful `hello`. Carries the **server-assigned** identity
of the newcomer, the current peer list, and the document state: either a
full snapshot or a patch tail (see [Reconnect](#reconnect-with-sinceseq)).

```json
{
	"type": "welcome",
	"protocolVersion": 1,
	"self": {
		"clientId": "p12",
		"identity": {
			"userId": "guest-1a2b3c",
			"name": "Guest Amber Fox",
			"color": "#e91e63",
			"role": "writer",
			"guest": true
		}
	},
	"peers": [
		{
			"clientId": "p9",
			"identity": {
				"userId": "u1",
				"name": "Alice",
				"color": "#2196f3",
				"role": "writer"
			}
		}
	],
	"seq": 42,
	"snapshot": {
		"id": "root",
		"type": "element",
		"tag": "div",
		"attrs": {},
		"children": []
	},
	"comments": []
}
```

- `self.clientId` is the server-assigned connection id; it is also the
  author id (`clientId`) on this client's broadcasts.
- `peers` lists everyone already in the room (the newcomer is not in its own
  list).
- `seq` is the current head sequence number of the document.
- `comments` is the full list of the document's comment threads
  ([`CommentThread`](#the-commentthread-shape)), ordered by `createdAt`.
- Exactly one of the following is present:
    - `snapshot`: the full document (fresh join, or `sinceSeq` too old);
    - `patches`: the missed tail, an array of
      `{ seq, clientId, patches }` entries (clean reconnect).

### `patches`

A sequenced broadcast: one entry of the canonical log. Sent to **every**
connected client, including the author.

```json
{
	"type": "patches",
	"seq": 43,
	"clientId": "p9",
	"opId": "p9#7",
	"patches": [
		{ "op": "text", "nodeId": "d1", "index": 5, "remove": 0, "insert": "!" }
	]
}
```

- `patches` is the batch **as sequenced**, i.e. already rebased by the
  server if the author was behind.
- `opId` is present **only on the author's own copy**: that is the ack.
  Everyone else receives the same entry without `opId`.

### `presence`

A peer's caret/selection, enriched with the server-side identity of that
peer (the client only sent a selection):

```json
{
	"type": "presence",
	"clientId": "p9",
	"identity": {
		"userId": "u1",
		"name": "Alice",
		"color": "#2196f3",
		"role": "writer"
	},
	"selection": { "nodeId": "d1", "offset": 5 }
}
```

Broadcast to everyone except the sender.

### `peer.join` / `peer.leave`

```json
{
	"type": "peer.join",
	"peer": {
		"clientId": "p12",
		"identity": {
			"userId": "u2",
			"name": "Bob",
			"color": "#4caf50",
			"role": "writer"
		}
	}
}
```

```json
{ "type": "peer.leave", "clientId": "p12" }
```

`peer.join` goes to everyone except the newcomer (who learns about itself
from `welcome.self`).

### `comment.updated` / `comment.deleted`

Comment broadcasts. See [Comments](#comments).

### `error`

```json
{
	"type": "error",
	"code": "base_seq_too_old",
	"message": "baseSeq 3 is not in range",
	"context": "submit"
}
```

- `code`: one of the [error codes](#error-codes) below.
- `message`: human-readable detail (for logs, not for parsing).
- `context`: **what the failed operation was**, so the client can react
  appropriately without parsing messages. One of `auth`, `submit`,
  `comment`, `protocol` (optional; see
  [the reaction table](#the-context-field-how-clients-must-react)).

### `pong`

```json
{ "type": "pong" }
```

## Error codes

| Code                | When                                                                                                                                                                                                                  | Context                                                      | Connection                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- |
| `auth_required`     | No token in `hello` and anonymous mode is off; a non-`hello` first message; `hello` not received within 10 s.                                                                                                         | `auth`                                                       | closed                                                           |
| `auth_failed`       | A token was presented but `checkAuthentication` rejected it (even in anonymous mode; there is no silent downgrade to guest).                                                                                          | `auth`                                                       | closed                                                           |
| `forbidden`         | `authorize` denied `read` on join (closes), `write` on submit, or `comment` on a comment operation; a comment ownership rule was violated; the [content firewall](security.md#the-content-firewall) rejected a batch. | `auth` on join, `submit` on submit, `comment` on comment ops | closed on join / open otherwise                                  |
| `bad_message`       | Schema validation failure (including a client trying to send its own name), a second `hello`, a `comment.add` with an already-used id, or an internal persistence failure on submit.                                  | `submit`/`comment` when known, else absent                   | closed if it happens before a successful `hello`, otherwise open |
| `doc_not_found`     | REST history request for an unknown document (HTTP 404).                                                                                                                                                              | n/a                                                          | n/a (REST)                                                       |
| `comment_not_found` | A comment operation targeted a thread id that does not exist (never created, or already deleted).                                                                                                                     | `comment`                                                    | open                                                             |
| `protocol_mismatch` | `hello.protocolVersion` differs from the server's `PROTOCOL_VERSION`.                                                                                                                                                 | `protocol`                                                   | closed                                                           |
| `base_seq_too_old`  | `submit.baseSeq` fell out of the in-memory transform window (see below). The client must re-join from a fresh snapshot.                                                                                               | `submit`                                                     | open                                                             |
| `rate_limited`      | The per-connection token bucket for submits/comment operations is exhausted (see [Security](security.md#rate-limiting)).                                                                                              | `submit` or `comment`                                        | open                                                             |

### `base_seq_too_old` and the sliding memory window

The server does **not** keep every historical document state. Per document,
`DocumentSession` keeps a **sliding window of the last `maxWindow` entries**
(and the document state after each of them), configured by
`sessionWindow` / env `SESSION_WINDOW`, default **512**. Anything older is
trimmed.

The oldest usable transform base is exposed as `windowStartSeq`; a valid
`submit.baseSeq` (and a delta-capable `hello.sinceSeq`) must lie in:

```text
windowStartSeq ≤ baseSeq ≤ seq
```

A `baseSeq` below `windowStartSeq` means the client is more than
`SESSION_WINDOW` sequenced entries behind. The server can no longer rebase
its batch and answers `error {code: "base_seq_too_old", context: "submit"}`.
The client must resync: drop optimistic state and re-join from a fresh
snapshot (the reference client does this automatically for every
`context: "submit"` error).

The window slides forward as entries are sequenced: right after a room is
loaded, `windowStartSeq` equals the seq of the snapshot the session was
restored from; after more than `maxWindow` further entries it starts moving.

### The `context` field: how clients must react

`ServerError.context` tells the client which recovery path applies. The
reference `CollabClient` implements exactly this table:

| `context`  | Meaning                                                                                                                                                                    | Required client reaction                                                                                                                                                           |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth`     | Authentication/authorization failed on the handshake. The server closes the socket.                                                                                        | The session is over. Fix the credential; the reconnect calls the token-provider function again, so a refreshed token is picked up automatically.                                   |
| `submit`   | A document batch was refused (`forbidden`, `base_seq_too_old`, `rate_limited`, persistence failure). The optimistic local state has **diverged** from the sequenced truth. | **Auto-resync**: drop local sync state, reconnect, load a fresh server snapshot. The reference client does this unconditionally on every `submit`-context error.                   |
| `comment`  | A single comment operation was refused (`forbidden`, `comment_not_found`, `rate_limited`). The document state is unaffected.                                               | Show a notice (e.g. "You can only edit your own comments"). No resync is needed; the authoritative thread state arrives via `comment.updated`/`comment.deleted` broadcasts anyway. |
| `protocol` | `protocol_mismatch`: client and server speak different protocol versions.                                                                                                  | Do not retry with the same build; surface a "please update" error.                                                                                                                 |
| _(absent)_ | A `bad_message` outside any operation flow.                                                                                                                                | Log it; it indicates a client bug.                                                                                                                                                 |

## Comments

Comments are a first-class part of the protocol. The design splits a comment
in two:

- the **anchor** lives _inside the document_ as a regular element attribute
  (`<span data-jodit-comment="c-42">…</span>`) and travels through the
  normal patch/OT machinery, so anchors move, split and merge with the text
  exactly like any other formatting, with no extra position-rebasing
  protocol;
- the **thread** (body, replies, resolved flag, author metadata) is stored
  by the server outside the document and synchronized with the dedicated
  messages below.

When the anchor is later deleted from the document (the commented text was
removed), the thread survives as an **orphan** and clients display its
`quote`, the anchored text captured at creation time.

### The `CommentThread` shape

```json
{
	"id": "c-42",
	"authorId": "u1",
	"authorName": "Alice",
	"authorColor": "#2196f3",
	"body": "Should this be a heading?",
	"quote": "Chapter one",
	"createdAt": 1751884800000,
	"resolved": false,
	"replies": [
		{
			"id": "c-42-r1",
			"authorId": "u2",
			"authorName": "Bob",
			"authorColor": "#4caf50",
			"body": "Yes, let's make it an h2.",
			"createdAt": 1751884900000
		}
	]
}
```

All `author*` fields and `createdAt` are **server-assigned** from the
connection's resolved identity, exactly like presence enrichment. Reply ids
are generated by the server.

### Client → server comment messages

All five schemas are **strict**: clients send ids and bodies only. A comment
message carrying `authorName`, `createdAt` or any other unknown key is a
`bad_message` validation error. Authorship cannot be forged from the
frontend. Limits: `commentId` 1-64 chars, `body` 1-10 000 chars, `quote` up
to 2000 chars.

> **Note: Comment bodies are plain text**
>
> Unlike document patches, comment `body` and `quote` are **not** run
> through the content firewall: the server length-caps them but does not
> sanitize markup. They are plain text by design, and clients **must**
> render them as text (the reference client uses `textContent`). A client
> that injects a body as HTML owns the resulting XSS. See
> [Security](security.md#the-content-firewall).

#### `comment.add`

Create a thread. `commentId` is client-generated and must equal the
`data-jodit-comment` anchor id the client put into the document (via a
normal `submit`); reusing an existing thread id is a `bad_message` error.

```json
{
	"type": "comment.add",
	"commentId": "c-42",
	"body": "Should this be a heading?",
	"quote": "Chapter one"
}
```

`quote` is the anchored text at creation time, shown when the anchor is
later deleted.

#### `comment.edit`

Replace the body of the **root** comment. Only the thread's author may edit
it (`forbidden` otherwise).

```json
{ "type": "comment.edit", "commentId": "c-42", "body": "Make this a heading?" }
```

#### `comment.reply`

Append a reply. Any user with the `comment` permission may reply to any
thread; the server assigns the reply id, author fields and timestamp.

```json
{ "type": "comment.reply", "commentId": "c-42", "body": "Yes, an h2." }
```

#### `comment.resolve`

Set or clear the resolved flag. Any user with the `comment` permission may
resolve or reopen any thread.

```json
{ "type": "comment.resolve", "commentId": "c-42", "resolved": true }
```

#### `comment.delete`

Delete a whole thread (with its replies). Allowed for the thread's author
and for any user with the `owner` role (moderation); `forbidden` for
everyone else. Deleting the anchor from the document is the client's job (a
regular `attr`/`remove` patch).

```json
{ "type": "comment.delete", "commentId": "c-42" }
```

### Server → client comment broadcasts

Comment operations are **not sequenced**: they do not carry a `seq` and do
not interact with the OT log. Every successful operation is broadcast to
**all** clients in the room (including the author):

- `comment.updated` is sent after `add`, `edit`, `reply` and `resolve`; it
  carries the **whole thread**, so clients simply replace their copy:

    ```json
    { "type": "comment.updated", "comment": { "id": "c-42", "...": "…" } }
    ```

- `comment.deleted` is sent after `delete`:

    ```json
    { "type": "comment.deleted", "commentId": "c-42" }
    ```

A joining client gets the full thread list in `welcome.comments`; afterwards
these two broadcasts keep it current. The reference client maintains a
`Map<id, CommentThread>` and emits the sorted list to the UI on every
change.

### Ownership and permissions

Every comment operation first requires the `comment` **action** (role
`commenter` or above, checked via the `authorize` hook or the built-in
role table). On top of that, per-thread ownership rules apply:

| Operation         | Required action | Ownership rule                                                     |
| ----------------- | --------------- | ------------------------------------------------------------------ |
| `comment.add`     | `comment`       | None (new thread; you become its author)                           |
| `comment.edit`    | `comment`       | **Author only**; even `owner` cannot rewrite someone else's words. |
| `comment.reply`   | `comment`       | Anyone with the action.                                            |
| `comment.resolve` | `comment`       | Anyone with the action (resolve and reopen).                       |
| `comment.delete`  | `comment`       | Author, **or any user with the `owner` role** (moderator).         |

Violations produce `error {code: "forbidden", context: "comment"}`; a
missing thread produces `comment_not_found`. See
[Authentication](authentication.md#per-operation-authorization) for the full
authorization story.

## Join flow

```text
Client                          Server                          Storage
  |  WS upgrade to /collab/ws     |                                |
  | ----------------------------> |                                |
  |  hello {protocolVersion,      |                                |
  |    docId, token?, sinceSeq}   |                                |
  | ----------------------------> |                                |
  |                               | resolveIdentity(token)         |
  |                               |   -> Identity | reject         |
  |                               | authorize(identity, docId,     |
  |                               |   'read')                      |
  |                               | [if room not in memory]        |
  |                               | -- load(docId) --------------> |
  |                               | <- snapshot + entry tail ----- |
  |                               | DocumentSession.replay(entries)|
  |  welcome {self, peers, seq,   |                                |
  |    snapshot | patches}        |                                |
  | <---------------------------- |                                |
  |                               | broadcast peer.join to         |
  |                               |   existing peers               |
  | load snapshot / apply missed  |                                |
  |   patches, create ClientSync( |                                |
  |   clientId, doc, seq)         |                                |
```

## The sequencing algorithm (server side)

The server is the **authoritative sequencer**. Per document it keeps a
`DocumentSession`: the canonical document, the head `seq`, and the recent log
entries (plus each intermediate document state, so any `baseSeq` in the
window can serve as a transform base).

On `submit {opId, baseSeq, patches}` from connection `clientId`:

1. **Authorize**: `authorize(identity, docId, 'write')`, else
   `error {forbidden}`.
2. **Range-check**: `baseSeq` must be within
   `[session.windowStartSeq .. session.seq]` (the
   [sliding memory window](#base_seq_too_old-and-the-sliding-memory-window),
   `SESSION_WINDOW` entries deep); otherwise `error {base_seq_too_old}`.
3. **Transform**: rebase the batch over every entry the author had not
   seen, in seq order, using the document state at each step as the transform
   base:

    ```ts
    let rebased = patches;
    let base = docAt(baseSeq);
    for (const missed of entriesAfter(baseSeq)) {
    	rebased = xformBatch(rebased, missed.patches, base).clientT;
    	base = docAt(missed.seq);
    }
    ```

4. **Assign seq**: the entry becomes
   `{ seq: session.seq + 1, clientId, opId, patches: rebased }` and the
   canonical document advances by applying it.
5. **Persist**: append the entry to storage **before** broadcasting; every
   `snapshotEvery` entries also store a snapshot. Submits are serialized per
   document (a per-room queue), so persist order equals seq order.
6. **Broadcast**: send the `patches` message to **all** clients in the
   room. The author's copy carries `opId` (the ack); everyone else's does
   not.

```text
Client A (baseSeq 10)      Server (seq 10)       Client B (baseSeq 10)
  | submit {opId "a#1",         |                        |
  |   baseSeq 10, P_a}          |                        |
  | --------------------------> |                        |
  |                             | <-- submit {opId "b#1", |
  |                             |     baseSeq 10, P_b} -- |
  | [A's submit arrives first]  |                        |
  | seq 11 := P_a (no missed entries)                    |
  | <-- patches {seq 11, clientId A, opId "a#1", P_a}     |  (ack to A)
  |                             | -- patches {seq 11,     |
  |                             |    clientId A, P_a} --> |
  | [B's batch is based on seq 10, missed entry 11        |
  |   -> transform]                                       |
  | P_b' = xformBatch(P_b, P_a, doc@10).clientT           |
  | seq 12 := P_b'              |                        |
  | <-- patches {seq 12, clientId B, P_b'}                |
  |                             | -- patches {seq 12,     |
  |                             |    clientId B,          |
  |                             |    opId "b#1", P_b'} -> |  (ack to B)
  |                                                       |
  | B rebased P_a over its pending P_b locally with the   |
  |   SAME xform; both editors converge on doc@12         |
```

## Client rebase rules (Jupiter-style)

The reference client (`ClientSync`) keeps:

- `serverDoc`: the canonical state at `lastSeq` (no local pending edits);
- `localDoc`: `serverDoc` + in-flight batch + buffer (what's on screen);
- **one in-flight batch** at a time; further local edits accumulate in a
  buffer.

Rules:

1. **Local edit** (already applied to the editor): if nothing is in flight,
   it becomes the in-flight batch and is sent as
   `submit {opId, baseSeq: lastSeq, patches}`. Otherwise it is appended to
   the buffer and **not** sent yet.
2. **Receiving a broadcast** sets `lastSeq = entry.seq`, then:
    - **Own ack** (`entry.opId === inflight.opId`): apply the server's
      rebased patches to `serverDoc` only; the editor already shows this
      change (both sides transformed identically). Clear the in-flight slot;
      if the buffer is non-empty, it becomes the next in-flight batch and is
      submitted with `baseSeq = lastSeq`.
    - **Foreign entry**: transform the incoming batch against the in-flight
      batch, then against the buffer (the incoming entry has the earlier
      canonical order, so it wins ties); replace in-flight/buffer with their
      rebased versions; apply the (twice-)rebased incoming patches to the
      editor and to `localDoc`; apply the original entry to `serverDoc`.

This is the classic Jupiter one-outstanding-operation model: because at most
one batch is in flight and the server transforms with the same `xform`
functions, client and server derive **identical** rebased patches for the
in-flight batch without ever exchanging transformed versions of it.

## Reconnect with `sinceSeq`

A client that reconnects **with no pending local edits** may send
`hello {sinceSeq: lastSeq}` instead of re-downloading the document. The
server answers with a delta when it can:

- delta is possible when `sinceSeq > 0` and
  `session.windowStartSeq ≤ sinceSeq ≤ session.seq` (the requested point is
  inside the [sliding memory window](#base_seq_too_old-and-the-sliding-memory-window))
  → `welcome.patches` contains `entriesSince(sinceSeq)` and no snapshot;
- otherwise (fresh join, or `sinceSeq` older than the window) →
  `welcome.snapshot` contains the full document at `welcome.seq`.

> **Warning: In-flight edits do not survive a disconnect**
>
> If the socket drops while a batch is unacknowledged, the client cannot
> know whether the server sequenced it. The reference client drops its sync
> state in that case and re-joins with `sinceSeq: 0`, loading a fresh
> snapshot.

## Transform rules

`xform(client, server, doc)` transforms two **concurrent** patches sharing
the same base document. `server` is the one with the earlier canonical order
(lower seq); **it wins ties**. Because everything except text splices is
id-addressed, most pairs are independent and pass through untouched. The
non-trivial cases:

| Concurrent pair                                                         | Rule                                                                                                                                                                                                                                                              |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` vs `text`, same node                                             | Classic character-level OT (retain/delete/insert component ops). Splices are converted to ops over the node's current text, transformed with the standard algorithm (earlier-seq inserts are placed first on ties), and converted back to splices.                |
| anything vs `remove` of a subtree containing its target                 | The edit inside the removed subtree is **dropped** (tombstone): `text`/`attr` on a removed node → `[]`; `insert` into a removed parent → `[]`.                                                                                                                    |
| `remove` vs `remove`, same node or nested                               | Removing the same node → both become no-ops (one `[]`, one kept as appropriate); removing a node inside what the other removed → the inner remove is dropped, the outer one survives.                                                                             |
| `insert` vs `remove` of its anchor sibling (`afterId === removed node`) | The insert is **re-anchored** to the removed node's previous sibling (or `null` = prepend).                                                                                                                                                                       |
| `insert` vs `insert`, same parent and same _effective_ anchor           | Deterministic order = seq order: the later (higher-seq) insert is re-anchored to chain **after** the earlier one's node (`afterId := earlier.node.id`). "Effective" anchor means an unresolvable `afterId` counts as `null`, matching the prepend tolerance rule. |
| `attr` vs `attr`, same node and same attribute name                     | **Last-writer-wins by seq**: the earlier patch is dropped from the transformed pair, so the later value ends up in the document.                                                                                                                                  |
| `remove` of the root                                                    | Treated as independent (it is a no-op on apply anyway).                                                                                                                                                                                                           |
| everything else                                                         | Independent; both pass through unchanged.                                                                                                                                                                                                                         |

Batches are transformed pairwise: `xformBatch(client, server, doc)` folds
`xform` over both lists, threading the intermediate document states, and
returns both rebased batches (`clientT` to apply after `server`, `serverT`
to apply after `client`).

## The convergence property

The invariant every implementation must satisfy, for any two concurrent
batches `A`, `B` over any document `d`:

```text
applyPatches(applyPatches(d, B), A′) === applyPatches(applyPatches(d, A), B′)
where { clientT: A′, serverT: B′ } = xformBatch(A, B, d)   // B has the earlier seq
```

In the reference package this is enforced by:

- an exhaustive pair-wise unit table (`tests/xform.test.ts`);
- **property-based tests** with fast-check generating random documents and
  random concurrent batches (`tests/convergence.property.test.ts`);
- a randomized client/server fuzz harness driving the actual
  `DocumentSession` + several `ClientSync` instances through interleaved
  edits and checking all replicas converge (`tests/session.test.ts`).

If you write your own server: reuse `xformBatch`/`applyPatches` from
`@jodit/collab-protocol` verbatim, or port them and run the same property
tests against your port. Convergence hinges on both sides transforming
_identically_, including the tolerance rules above.
