FalCAD collaboration protocol — v1 (draft)
This document is the public, implementation-independent contract between FalCAD clients and any collaboration server (the hosted FalCAD cloud, or a self-hosted one). The reference implementation is src/collab/ in this package. Conformance vectors live in ../test-vectors/collab, and every implementation must pass them.
Principles
- Validated transactions, not last-writer-wins. Edits to electrically or geometrically meaningful state are transactions of entity operations. The server checks them against the schema and referential integrity, then accepts or rejects them atomically.
- One authoritative order per document. The server serializes accepted transactions into a log. Every client applies the same transactions in the same order and ends up with identical documents.
- Entity-level conflict detection. Concurrent edits to different entities merge automatically. Concurrent edits to the same entity (or document property) are never silently overwritten: the later one is rejected with
conflict, and the client rebases. - No numeric identifiers. Documents, entities, transactions and snapshots are identified by random UUID v4. Sequence positions exist only inside the server.
- No timers. Ordering comes from the server stream. Clients don’t poll, debounce or use time-based reconciliation.
- Presence is ephemeral. Cursors, selections and viewports are the only last-writer-wins state. They are never persisted and carry no design meaning.
Data model
The entity model comes from @falcad/format’s DOCUMENT_MODELS, exported as schema/collections.json. For each structured document kind (schematic, board, simulation) it lists:
- the collections (top-level arrays of entities that have an
id), - each collection’s entity JSON Schema,
- the reference fields (
components.sheetId → sheets, …), - the editable scalar properties (
name,grid, …).
Operations
{ "op": "create", "collection": "components", "value": { "id": "<uuid v4>", ... } }
{ "op": "update", "collection": "components", "id": "<uuid>", "set": { "value": "4k7" }, "unset": ["footprint"] }
{ "op": "delete", "collection": "wires", "id": "<uuid>" }
{ "op": "set", "property": "name", "value": "Power stage" }
create:value.idmust be a lowercase UUID v4 that isn’t used anywhere in the document.valuemust validate against the entity schema.update: replaces top-level fields (set) and removes optional ones (unset). The merged entity must validate.idis immutable.delete: removes the entity. Any entity that references it must be deleted in the same transaction, otherwise integrity fails.set: only for listed properties. The value must validate against that property’s schema.
Values are stored exactly as sent once they validate. Implementations must not fill in defaults, so every implementation builds byte-identical documents from the same stream.
Transactions
{
"id": "<uuid v4, client generated>",
"documentId": "<uuid>",
"base": "<id of the last transaction or snapshot the client had applied>",
"ops": [ ... ], // at least one
"message": "optional"
}
A server processes a submitted transaction in this order:
- Validate the shape →
invalid-transaction. - Check
documentId→wrong-document. - If
idmatches an already accepted transaction, reply accepted (duplicate) without changing anything. This makes retries idempotent. - Locate
basein the log →unknown-baseif absent. The client must resynchronize from a snapshot. - Conflict check: compute touched keys (
collection/idfor every op,@propertyforset). If any transaction accepted afterbasetouched one of the same keys →conflict, withconflictsWithlisting those transaction ids. - Apply the ops to a copy of the head document, in order. Stop at the first failing op:
unknown-collection,unknown-property,duplicate-id,not-found,invalid-entity,immutable-field. - Check referential integrity and whole-document constraints →
integrity. - Append to the log, advance
head, then broadcast to every participant, including the sender. The broadcast doubles as the sender’s acknowledgement.
Steps 5–8 run under one per-document lock (or equivalent serializable transaction). A rejection changes nothing.
Client behaviour
- Edits are applied optimistically. A client edits on top of
confirmed + pendingand sends each transaction withbaseset to the id of its latest pending transaction, or to the confirmed head when nothing is pending. - On a broadcast: if it is the client’s oldest pending transaction, the client drops it from the queue. Otherwise the transaction came from someone else: the client applies it to
confirmedand rebuilds the view. - On a rejection: the client drops that transaction and all later pending ones, since their bases chain through it. It rebuilds the view and tells the user, who can re-apply the edit against the new state.
- On
unknown-baseor divergence: the client fetches a fresh snapshot.
Transport: real-time hub
The hosted service exposes a SignalR hub at /v1/realtime/collab. It authenticates with a bearer access token; WebSocket clients pass it as access_token. Messages are JSON.
| Direction | Method | Payload | Result / notes |
|---|---|---|---|
| C→S | JoinDocument |
{ projectId, documentId } |
{ kind, snapshot, head, role, canEdit, editorSeat, participants[], sessionId, plan, seatLimit } (seatLimit is the project’s simultaneous-editor limit; null when unlimited). Requires view permission. A user with edit permission takes an editor seat if the project’s simultaneous-editor limit allows; otherwise they join read-only with editorSeat: false. |
| C→S | LeaveDocument |
{ documentId } |
Releases the seat. Disconnecting also releases it. |
| C→S | SubmitTransaction |
Transaction |
{ status: "accepted", head, duplicate } or { status: "rejected", rejection }. Requires edit permission and a held editor seat. |
| C→S | CatchUp |
{ documentId, base } |
{ transactions[], hasMore } or { resync: true }. Call again from the last transaction while hasMore is true. Results are paged on the server, never unbounded. |
| C→S | UpdatePresence |
Presence (with the sessionId returned by JoinDocument) |
Broadcast to the other participants. Not persisted. |
| S→C | TransactionAccepted |
{ documentId, tx, head } |
In log order. |
| S→C | PresenceUpdated |
{ userId, displayName, color, presence } |
|
| S→C | ParticipantJoined |
{ documentId, participant: { sessionId, userId, displayName, color, editorSeat } } |
Sent to everyone already in the document. |
| S→C | ParticipantLeft |
{ documentId, sessionId } |
|
| S→C | AccessRevoked |
{ projectId } |
Sent when the user’s access ends (membership removed, role lowered). The server also drops the user from the group. |
Authorization runs on every call, not only on join. Membership and role are re-read from the database for every SubmitTransaction, so a revoked editor can’t keep editing over an open socket.
Conformance
A conforming implementation, for each test-vectors/collab/*.json:
- starts a log from
snapshot/snapshotIdfordocumentId, - submits each
steps[i].txand gets the expectedstatus(and rejectioncode), - ends with a document deep-equal to
final.