## Overview

Everyone who opens the editor works on **one shared draft** of the flow, kept on the connector in
`<data_dir>/flow.draft.json`. Changes by anyone (adding, moving, connecting and deleting nodes, typing in a field,
adding or renaming a flow) reach every open editor within about a tenth of a second and survive a page reload and a
connector restart. The running flow and the version history change only at **Deploy**.

- **Avatars** in the top bar list who is in the editor (the name comes from the OIDC sign-in or the API token). The list
  shows what each person is doing and offers **Follow**, which mirrors that person's flow and viewport until you pan or zoom.
- **Cursors** of the others are drawn on the canvas. A node somebody else has selected or has open gets a ring in
  their colour with their name, and the side panel says *Ann is editing this node* while she works in its form.
- The **Draft** chip next to Deploy counts the unsaved changes against the running flow and names who wrote them.
- **Viewers and operators** watch live but cannot edit. A **protected environment** is read-only for everyone (presence
  still works); its draft follows the live flow after every promotion or rollback.

## Deploy and discard

| Action | What happens |
| --- | --- |
| **Deploy** (`POST /api/collab/deploy {message?, if_version?}`, editor, audited `flow.deploy`) | Takes the shared draft as it is now, creates the version with the caller as **actor** and everyone else who changed the draft since the previous version as **co-authors** (`co_authors` in `GET /api/versions`, shown in History), then announces *deployed vN by X* to every open editor. 409 `no_changes` when the draft equals the live flow, 409 for a stale `if_version`. Document-level fields the canvas does not edit (for example `mocks`) are taken from the live flow. |
| **Discard draft changes** (`POST /api/collab/discard`, editor, audited `flow.discard`) | Resets the draft to the running flow for **everyone**. The editor asks for confirmation first. Changes still queued on offline machines are void (those clients are told). |
| A deploy by another route (`PUT /api/flow`, MCP, rollback, promotion, import) | Announced to everyone as well; the draft is kept and the unsaved count is taken against the new live flow. The authors list restarts, because a new version exists. |

`GET /api/collab/draft[?flow=true]` returns `{epoch, seq, base_version, live_version, unsaved:{count, added, removed, changed},
authors[], peers[], protected}` (plus the draft document with `flow=true`).

## How changes merge

The server holds the draft as nodes, edges and flows with plain JSON fields and **sequences every change**; clients apply
the same sequence, so they converge without version vectors. The rules, per field, are deterministic:

- Different fields of one node (`x`, `label`, `script`, …) both survive, whoever sent first.
- The same field: the change the server saw last wins (last-writer-wins per field).
- A **delete wins** over a concurrent move or edit of the same node: those find no target and are dropped. Deleting a
  node removes its edges; deleting a flow removes its nodes. Adding the same id again brings it back.
- A connection to a node that was just deleted is dropped. A connection that would make a cycle, duplicate an existing
  one or cross flows, and deleting the last flow, are **refused**: the client that sent them is told and resynchronised.
- **Undo** takes back your own edits; other people's changes made since are kept.

This was chosen over Yjs / `yrs` because the draft is a small graph of JSON fields that one server already orders: the
merge needs no tombstones or binary document format and adds no dependency. The price is that **text is merged per
field, not per character**: two people typing into the same script at the same moment is last-writer-wins.

## Offline and reconnect

The editor reconnects with backoff. While offline the top bar shows *Offline · N queued*; your edits stay on the canvas,
are merged (consecutive edits of one node become one change) and are sent after the editor caught up with what happened
meanwhile. A reconnecting client sends the last `epoch` and `seq` it applied and gets either the ops it missed or, when
too much happened (more than 4096 changes, a connector restart or a new epoch), the whole draft; its queued changes are
replayed on top, and the server ignores ones it already applied (each change carries a unique id). If the draft was
**discarded** while a client was offline (new epoch), its queued changes are dropped and the user is told. Closing the
tab with unconfirmed queued changes asks for confirmation; everything the connector confirmed is safe.

## Wire protocol

`GET /api/ws/collab` (any role; same bearer token / `?token=` / session cookie as the other sockets). JSON text frames.

| Client → server | |
| --- | --- |
| `{type:"hello", epoch?, seq?}` | First frame. The last epoch and seq this client applied. |
| `{type:"ops", ops:[{cid, op}]}` | Edit the draft (editor role, not in a protected environment; at most 500 ops per frame). Anyone else gets `{type:"error", code:"read_only", reason:"role"\|"protected", cids}`. |
| `{type:"presence", cursor, selection, editing, flow, view}` | Cursor and viewport in flow coordinates, selected node/edge ids, the node being edited. Every role; rate-limited. |
| `{type:"resync"}`, `{type:"ping"}` | Full state / keep-alive (a silent client is dropped after 90 s). |

Ops (`op.t`): `node_add {node}`, `node_set {id, set, unset}`, `node_del {id}`, `edge_add {edge}`, `edge_del {id}`,
`flow_add {flow}`, `flow_set {id, set, unset}`, `flow_del {id}`.

| Server → client | |
| --- | --- |
| `welcome` | `client`, `you`, `readonly` + `reason`, `epoch`, `seq`, `mode` `full` (+`state`) or `delta` (+`ops`), `peers`, `authors`, `base_version` |
| `ops` | `{client, user, ops:[{seq, cid, op}]}` to everyone including the sender (the sender's echo is its acknowledgement) |
| `ack`, `refused`, `error` | To the sender: nothing left to do for these cids / these cids were refused (`code` `invalid` or `conflict`) / read-only and others |
| `presence`, `joined`, `left`, `authors` | Who is where; who changed the draft |
| `deployed` | `{version, actor, message, co_authors, via:"collab"\|"external"}` |
| `reset` | The draft was discarded: new `epoch` and the full `state` |
| `state` | Full state after a `resync` or when a client fell behind |

The draft file is written 150 ms after the last change and again at shutdown; an unreadable file is kept as
`flow.draft.json.corrupt` and the draft starts from the live flow. Back it up with the rest of the data directory.