Skip to content

Sync Protocol v2 ​

This page is the reference for what travels between a Kora client and the sync server. You do not need it to build an app; it is for operators, protocol implementers and anyone debugging a deployment. Kora 1.0.0-beta.13 and later speak protocol 2. Kora 1.0.0-beta.12 and earlier speak protocol 1.

Protocol 2 is one wire bump carrying every wire change of the remediation programme:

  • Content-hash version 2. New operation ids also cover previousData, sequenceNumber, causalDeps (as a set) and schemaVersion. hashVersion travels with each operation and is verified on receive.
  • Encryption envelope v2. Ciphertext lives in op.encrypted, bound to the operation by AES-GCM additional data, and keys come from a server-stored wrapped keyring. See Sync Encryption.
  • Sequence reservation. A protocol-2 client reserves each operation's sequence number in the same local transaction that writes it, so it never puts two operations under one (nodeId, sequenceNumber).
  • Server-authored metadata. authoritativeNodeIds and revokedAuthoritativeNodeIds in the handshake response; foldState on scope-entry operations.

The constants live in @korajs/sync: SYNC_PROTOCOL_VERSION (2), INVALID_OPERATION_ID, PLAINTEXT_REJECTED and PROTOCOL_V1_DEPRECATED.

Message flow ​

Client                                      Server
  |--- handshake (vector, watermark, scope) -->|  refused: every other message before it
  |<-- handshake-response (accepted scopes) ---|
  |<-- encryption-key-* (encrypted apps) ----->|  keyring side channel
  |--- operation-batch (own missing ops) ----->|
  |<-- acknowledgment / operation-rejected ----|
  |<-- operation-batch (base..max delivery) ---|  delivery stream, chained
  |--- acknowledgment (deliverySequence) ----->|
  |<-- heartbeat (every 25 s) -----------------|
  |    awareness-update, yjs-doc-update, blob-chunk-* (side channels, scoped)
  • The handshake comes first. The server refuses every other message until an authenticated handshake has completed (HANDSHAKE_REQUIRED), on WebSocket and HTTP long-poll. A connection has handshakeTimeoutMs (default 10 s) to send it.
  • Uploads (client to server) are decided by version vectors: the client sends the operations of its own nodes that the server's vector does not cover, in causal order, in batches of batchSize (default 100; the server refuses batches over maxOpsPerBatch, default 1000, with BATCH_TOO_LARGE).
  • Downloads (server to client) are a gap-free delivery stream (below).
  • Each operation the server refuses is answered with operation-rejected (operation id, code, message, retriable); the rest of the batch is acknowledged. Connection-level problems are error messages. A NODE_ID_CLAIMED error from a beta.14 server also carries nodeOwnership ('other-principal' or 'unowned'), which protobuf carries in its residual field.

Delivery watermark ​

Every stored operation has a monotonic delivery sequence. Each client persists a delivery watermark per view (scope plus query subsets): the highest delivery sequence up to which it has applied, or durably quarantined, every in-scope operation with no gap.

  • Server batches chain baseDeliverySequence -> maxDeliverySequence. The client applies a batch only when its watermark equals the base, and advances it only after the batch is fully applied. The batch's operations, the quarantine rows of the ones it could not apply and the watermark advance commit in ONE storage transaction (each operation in its own savepoint), so a crash leaves the whole batch with its watermark or none of it. A batch that cannot be fully applied (a retriable failure) is rolled back and applied operation by operation, which stops the watermark at the batch's base as before; so do batches with an access narrowing or a retraction of a record that has unsent writes. A dropped or failed batch stalls the watermark and is re-sent; nothing is skipped.
  • The handshake reports the watermark (lastDeliverySequence), and the server resumes just after it. A server whose log was rolled back (a restore) reports serverMaxDeliverySequence below the client's watermark; the client then resets to 0 and resyncs.
  • acceptedScopeKey and acceptedScopeWatermark resume an auth-scoped client's accepted view after a reconnect instead of restarting it.
  • First sync streams in chunks with backpressure: the server pauses a client's stream above deliveryHighWaterBytes (1 MiB) and disconnects a consumer that leaves more than maxBufferedBytes (32 MiB) unsent.
  • Live delivery chains from a send cursor; unacknowledged batches are retransmitted on a timer (relayRetransmitIntervalMs, 2 s).
  • A batch's retractions (records leaving the client's view, scopeExit: 'retract' and access rules) are part of the stream. Each carries afterOperations, the number of the batch's operations before it, and the client applies operations and retractions in that order: a record inserted and moved out of the view in one batch ends hidden, and one that leaves and comes back ends visible. A client declares this with orderedRetractions in its handshake. For a client that does not (1.0.0-beta.14 and older apply every retraction before the batch's operations), the server cuts the batch before a retraction whose record has an earlier operation in it. A client without the watermark gets relays instead: a relay that retracts a record waits until every earlier relay carrying an operation of that record is acknowledged, and later relays wait behind it. A client that stops acknowledging is disconnected; its unacknowledged relays go to a per-instance buffer (newest 5000 operations per device, 5 minutes) that is re-relayed when it reconnects.
  • Delivery batches carry operationSequences (each operation's delivery sequence; a scope entry's is its trigger's + 0.5) and each retraction its deliverySequence. A retracted record's visibility follows delivery order: whichever is later, its latest delivered operation or its latest retraction, decides. So a quarantined insert replayed after its record's retraction stays hidden, a duplicate re-delivery of an earlier operation does not show it, and any operation delivered after the retraction (a scope entry, an update, an insert onto the existing record) shows it again. A stream that restarts lower (another view, a rollback, forceFullResync) moves the earlier run's positions before itself, so the new run decides. Without positions (relays, older servers) the earlier rule holds: a new insert or a scope entry shows the record, an update or a duplicate does not.
  • An insert onto an existing record (an explicit-id server write) enters or leaves a scope like an update: it gets a scope entry, or a retraction.
  • KoraSyncServer.forceFullResync({ userId }) (or { nodeId }) restarts a device's delivery from 0 at its next handshake, by advertising a frontier below its watermark. A device that kept a record outside its view because of a bug is repaired by that replay against a fixed server; re-scoping is not enough, because it judges the device's own (stale) values. A device keeps a watermark per delivery view (scope and query subsets), so the request completes per view: each view restarts from 0 the first time the device streams under it after the request. A custom ServerStore must implement requestFullResync, getFullResyncState and completeFullResync together; otherwise forceFullResync refuses with FULL_RESYNC_UNSUPPORTED.

The design note is docs/design/durable-delivery.md in the repository.

Quarantine ​

An operation the client cannot apply yet is not dropped. It is stored in _kora_unapplied_ops (and the watermark passes it) when:

CodeCauseApplies on a replay once
DECRYPT_FAILEDno key, a wrong key (KEY_ID_MISMATCH), a plaintext op under encryption (PLAINTEXT_REJECTED), a protocol-1 encrypted payload (LEGACY_ENCRYPTED_PAYLOAD)the keyring unlocks or gains the key (replayed at once)
INVALID_OPERATION_IDthe id is not the content hash it declaresnever (kept for inspection)
SCHEMA_TRANSFORM_UNAVAILABLE / SCHEMA_TRANSFORM_INVALIDno transform path to this client's schema version, or a transform broke its contractthe app upgrades
REMOTE_CLOCK_DRIFT, INVALID_TIMESTAMP_FIELDSthe operation is stamped too far in the futuretime catches up
FOLD_STATE_INVALIDthe record's merge state cannot take it even after one re-foldan upgrade
REFERENTIAL_INTEGRITYnot appliable in this view (a child whose parent is outside it)the parent is present

The quarantine is replayed on every start and whenever the keyring gains keys (and on demand with the sync engine's retryQuarantinedOperations()); each quarantine and release is an event (sync:apply-failed, sync:apply-recovered). A failure that may be transient (a busy database) instead stalls delivery (sync:apply-blocked, status blockedFailure) and the server re-sends.

Liveness ​

  • The server pings every WebSocket every heartbeatIntervalMs (25 s) and terminates a connection that leaves two pings unanswered.
  • Browsers cannot see pings, so a client that sets supportsHeartbeat also receives an application heartbeat message every appHeartbeatIntervalMs (25 s, announced as heartbeatIntervalMs in the handshake response). A client that hears nothing for about 2.5 intervals treats the connection as dead and reconnects.
  • A reconnect succeeds only once the new session reaches streaming. The backoff starts at reconnectInterval (1 s), doubles up to maxReconnectInterval (30 s) with 25% jitter, and resets only after a connection stayed up for 10 s.

Handshake fields ​

MessageFieldMeaning
handshakeprotocolVersion2. Absent means protocol 1 (Kora 1.0.0-beta.12 and earlier).
handshakenodeId, versionVector, schemaVersionThe device's node, what it holds, its schema version.
handshakeauthTokenThe credential, unless the transport sends it out of band.
handshakesyncScope, syncQueriesWhat the client asks for; the server's grant can only be narrowed by it.
handshakelastDeliverySequence, acceptedScopeKey, acceptedScopeWatermarkWhere the delivery stream resumes.
handshakenodeTokenAn anonymous device's secret for its node id.
handshakesupportsHeartbeat, sequenceReservationCapabilities; a protocol-2 client always sends sequenceReservation: true.
handshakesupportedWireFormatsAlways ['json']: protobuf is never negotiated (below).
handshake-responseaccepted, rejectReason, supportedSchemaMin/MaxThe verdict.
handshake-responseacceptedScope, acceptedDownlinkScopes, acceptedUplinkScopesThe scopes the server granted.
handshake-responseserverTime, serverMaxDeliverySequence, heartbeatIntervalMs, blobStorageEnabled, blobPossessionProof, nodeTokenSession facts.
handshake-responseauthoritativeNodeIdsThe explicit authoritative node ids: legacy server ids (from before beta.13) and configured extras. Every kora:server: id is authoritative by its prefix whether listed or not. Clients keep the union of every id they learn and re-fold affected records when it grows.
handshake-responserevokedAuthoritativeNodeIdsExplicit ids the deployment revoked (permanent): clients drop them for good and re-fold.
handshakeprobeOperationIdsAt most 500 operation ids of a node the client's database retired (below): which does the server store?
handshake-responseoperationProbe{ stored }: the asked ids the server stores. Absent when nothing was asked, the question was malformed or oversized, or the server predates it.

Retired nodes (shared browsers) ​

A database namespaced by its user (store.namespaceByAuthUser) whose pinned node moved retires the previous node: the user's device id changed, typically because the browser is shared between accounts and each account now signs in with a device id of its own. beta.12 gave every account on a browser the same device id, so two users' databases may have authored under the retired node, each numbering from 1: the server's version-vector entry for it says nothing about which of THIS database's writes the server holds. A retired node is never used for a session, adopted or handed over. At a handshake of the store's own node the client asks about the retired node's operations above its settled prefix (probeOperationIds, oldest first, never one refused for good); the stored ones are settled, and the rest are re-authored under the store's own node (new content ids, causal deps rewritten, sync:operations-rewritten with reason node-rotation) and upload in that session. A server that does not answer leaves them pending: nothing is re-authored without its word.

Every operation of the retired node above its settled prefix is asked about, queued or not: beta.12 dropped the queue entry of a write it wrongly counted as synced (the shared node's vector entry, raised by the other database, covered it). Only an operation this database recorded as received under one of its own node ids (from this release on) is the other database's: it is settled, never re-authored. A beta.12 database recorded nothing, and needed nothing: a beta.12 server never delivered a session's own node back to it, so what it holds under that node it wrote.

A node the database knows only from its outbound queue (registered at open because queue entries named it as their author, never the database's own node) is asked about the same way. beta.12 put operations a tab RECEIVED from the user's other device into its queue when it reloaded, so after the upgrade that device's node read as this database's unassigned held writes. At a handshake of the store's own node the client asks by id (at most 500 per handshake); the operations the server stores are settled (sync:local-node, action queue-settled), and only those: an operation the server lacks stays held as before, so an unsent write is never settled. The re-authored operations, their outbound-queue entries and a persisted old-to-new id map (causal deps of a later batch follow it) are written in one transaction. Tabs pinned to a node hold its Web Lock in shared mode, and the repair takes it exclusively from the question to the re-authoring: a retired node a tab still has open (writing, and uploading, under it) is not asked about until that tab is gone.

No device may hand-shake with a kora: node id, the server's node id or any authoritative id, current, legacy or revoked (INVALID_NODE_ID, not retriable). A protocol-1 client is accepted, deprecated, in 1.0.0-beta.13 and beta.14: the server logs session.protocol_deprecated and emits sync:protocol-deprecated. A later release refuses it, announced in its release notes.

Watching for protocol-1 clients ​

Each server process counts the protocol-1 sessions it accepted: legacyProtocolSessions and lastLegacyProtocolSessionAt (epoch ms, or null) in /__kora/status and kora status, and kora_legacy_protocol_sessions_total on /__kora/metrics. The counter is in memory and starts at zero when the process starts. To check that no instance accepted a protocol-1 session in the last two weeks (increase handles the resets of restarted instances):

promql
sum(increase(kora_legacy_protocol_sessions_total[14d])) == 0

Zero means zero accepted protocol-1 sessions: a session is counted when its handshake succeeds. A beta.12 device refused before that (failed authentication, a node claimed by another user, a client too old for the schema's access rules) is not counted. Sessions accepted after the last scrape before a crash or restart are missing from the metric. The session.protocol_deprecated log event is the durable record across restarts, so check it as well.

Operation fields ​

FieldTypeHashed (v2)Notes
idstringSHA-256 content hash of the canonical body.
nodeId, type, collection, recordIdstringyes
data, previousDataobject or nullyesCanonical JSON (see Schema Design).
timestamp{ wallTime, logical, nodeId }yesHLC.
sequenceNumbernumberyesUnique and gap-free per node.
causalDepsstring[]yes (as a set)
schemaVersionnumberyes
hashVersion1 | 2domain tagAbsent means 1. Persisted on the client and every server store. Operations whose id is not a content hash (server side effects, constraint corrections, scope entries) never declare it.
atomicOps, transactionId, mutationNameRide inside the data JSON.
fieldVersions, foldStatenoServer-authored (scope entries). The server strips them from every device upload.
encryptedenvelopeno{ v: 2, alg, keyId, keyVersion, data, previousData, atomicOps? }, each member { iv, ct }. data is then null or the cleartext fields; the id is the v2 hash of the plaintext. Every server store keeps it verbatim and relays it.

Verification ​

WhereWhatOn mismatch
Server ingestA plaintext operation declaring hashVersion: 2 is verified as uploaded, after authorization, timestamp and size checks, before validators and transforms. An operation without hashVersion is verified as a version-1 hash. An unknown version fails closed. Encrypted operations cannot be verified by the server (no plaintext).INVALID_OPERATION_ID, not retriable; never stored or relayed.
Server ingest, duplicatesAn upload reusing a stored id is a duplicate only when every hashed field is equal.FORGED_DUPLICATE, not retriable, no effect; logged, emitted (sync:forged-duplicate) and counted.
Client, after decryptionEncrypted operations always; plaintext operations declaring version 1 or 2. Reserved kora: system nodes are exempt.Quarantined (INVALID_OPERATION_ID), never released.

Protocol-1 sessions: an operation of a protocol-1 client whose undeclared id cannot be verified (Kora 1.0.0-beta.12 hashed undefined members as null, which the JSON upload no longer holds) is stored unverified for that session's own node, with a warning (session.unverified_legacy_operation, event sync:unverified-legacy-operation, metric unverifiedLegacyOperations). A beta.12 update(id, { field: undefined }) is stored with that field null, as the beta.12 client applied it.

Local rewrites of a version-2 operation before it is shared (clock rebase, node rotation, SEQUENCE_CONFLICT renumbering) re-hash it; never-sent operations naming it in causalDeps are rewritten in the same transaction.

Wire format ​

JSON on every transport. ProtobufMessageSerializer is lossless for every message type (members without a native slot ride in field 49, extJson), but it is not negotiated: clients advertise supportedWireFormats: ['json'] and the server reports the format its transport frames with. Use protobuf only as an explicit choice on both ends of a transport you control. Encryption-key messages are always JSON. WebSocket messages of 1 KiB or more are compressed with permessage-deflate unless the server disables it.

Protobuf field table ​

Envelope (SyncEnvelope):

#FieldType#FieldType
1typestring26operationIdstring
2messageIdstring27collectionstring
3nodeIdstring28recordIdstring
4versionVectorrepeated {1 key, 2 value}29lastDeliverySequenceint64
5schemaVersionint3230baseDeliverySequenceint64
6authTokenstring31maxDeliverySequenceint64
7supportedWireFormatsrepeated string32deliverySequenceint64
8acceptedbool33serverMaxDeliverySequenceint64
9rejectReasonstring34acceptedScopeJsonstring
10selectedWireFormatstring35acceptedDownlinkScopesJsonstring
11operationsrepeated SyncOperation36acceptedUplinkScopesJsonstring
12isFinalbool37retractionsJsonstring
13batchIndexuint3238scopeExitPolicystring
14acknowledgedMessageIdstring39nodeTokenstring
15lastSequenceNumberint6440blobPossessionProofbool
16errorCodestring41throttledbool
17errorMessagestring42retryAfterMsint64
18retriablebool43acceptedScopeKeystring
19serverTimeint6444acceptedScopeWatermarkint64
20requestIdstring45sequenceReservationbool
21hashstring46authoritativeNodeIdsrepeated string
22chunkBytesstring (base64)47protocolVersionuint32
23hasBytesbool48revokedAuthoritativeNodeIdsrepeated string
24unused, never reuse49extJsonstring ({ set?, unset? })
25blobStorageEnabledbool

Operation (SyncOperation, envelope field 11):

#FieldType
1-5id, nodeId, type, collection, recordIdstring
6, 7dataJson, previousDataJsonstring
8timestamp{1 wallTime int64, 2 logical uint32, 3 nodeId string}
9sequenceNumberint64
10causalDepsrepeated string
11schemaVersionint32
12, 13hasData, hasPreviousDatabool
14hashVersionuint32 (absent = 1)
15foldStatestring
16encryptedJsonstring (envelope JSON)

Older decoders skip unknown fields. The unused DynamicProtobufSerializer was removed.

Compatibility ​

Upgrade sync servers first, then clients. Verified against the last published release, 1.0.0-beta.12 (tag v1.0.0-beta.12), with node scripts/remediation/compat-beta12.mjs <beta12-build> (clients and servers both ways, databases upgraded in place, mixed fleets under chaos; results in remediation/evidence/compat-beta12.md) and node scripts/remediation/protocol-v2-compat.mjs <beta12-build>, plus the unit and integration suites.

ClientServerResult
protocol 2protocol 2Full protocol 2: ids verified on both sides, encrypted operations stored opaquely.
protocol 1 (beta.12)protocol 2Accepted for this release with a deprecation warning. Its version-1 ids are verified where beta.12's hash form can be rebuilt (undefined members, binary values, a Date inside a json value, which beta.12 hashed as {}) and the rest are stored and relayed unverified for its own node; protocol-2 clients converge with it. Until it upgrades, the beta.12 client itself keeps beta.12 merge semantics for concurrent edits (its first open of beta.13 re-folds every record). Its encrypted payloads (per-device keys) are refused by protocol-2 clients with encryption on. The beta.13 security rules apply to it too: the server grants scopes, the node must be claimable, and an HTTP long-poll client must be upgraded.
protocol 2protocol 1Plaintext sync converges, but an old server drops hashVersion (relayed operations are treated as version 1) and op.encrypted (encrypted sync does not work: receivers quarantine). There is no key service (KEY_SERVICE_UNSUPPORTED). Do not run this way: upgrade the server first.