When the device holds the authoritative copy until it can sync, the data model has to survive concurrent edits, crashes mid-write, deletes that must propagate, records moving in and out of scope, and datasets too large to move at once. This spec defines identity, versioning, conflict resolution, scope, failure handling, and scale so two devices editing the same record converge on the same result and no acknowledged change is silently dropped.
Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice. The convergence and no-loss properties hold under the assumptions stated here — collision-resistant IDs, a single authoritative server ordering writes, and the conflict rules below.
Entity model
Entity-relationship model
Every syncable record carries a globally-unique ID, separate client and server versions, timestamps, a soft-delete tombstone, and a dirty flag marking local changes not yet pushed. A per-device sync checkpoint records how far the last successful sync reached.
Definition
Identity
Record IDs are generated on the client and MUST be collision-resistant across devices — UUIDv4/v7 or equivalent, never sequential integers or timestamp-plus-hash — so two devices editing offline do not mint the same ID for different records. (A UUID collision is not literally impossible; it is negligible at any realistic scale, and that negligibility is the property relied on here.)
Definition
Versioning strategy
Each record's version is a monotonically increasing integer per record. Client and server versions are tracked separately: client_version advances on every local edit; server_version is the version the server assigned when it last accepted the record. The two diverge exactly while the device holds unpushed local edits.
A push request carries the base_version — the server_version the client last saw for that record. The server detects a conflict by comparing base_version against the record's current server_version. If they match, the write applies cleanly and the server assigns the next server_version; if base_version is behind, another device wrote in between and the change enters conflict resolution rather than blindly overwriting.
-- Applies only if the client's base_version is still current.
-- Zero rows affected => a conflict; route to conflict resolution.
UPDATE records
SET payload = :payload,
server_version = server_version + 1,
updated_at = now()
WHERE id = :id
AND server_version = :base_version;A push MUST include the base_version; a write received without one MUST be treated as a conflict, never a blind overwrite.
Definition
Conflict resolution
On a detected conflict, resolution is either whole-record or field-level. Whole-record picks one winner by a fixed total order — server_version, then server-authoritative timestamp, then a stable tiebreak (device id) — so every peer reaches the same result independent of sync order. It is simple and lossy: the loser's fields are discarded. Use it where a record's fields are not independently meaningful.
Field-level merge resolves each field independently and preserves concurrent edits to different fields, but it is safe only for field types with a well-defined merge. Commutativity is a property of the field type, not a blanket guarantee: text and counters merge order-independently; an arbitrary scalar does not. For a field without a safe automatic merge — a status enum, a price, anything governed by business rules — a custom resolver MUST be defined, otherwise the field falls back to whole-record last-writer-wins.
A custom resolver is a deterministic pure function `(base, local, remote) => resolved`, registered per field or record type and run identically on the server and every client so all peers converge. A resolver that is non-deterministic, or differs across peers, breaks convergence and MUST NOT be used.
| Field type | Merge strategy |
|---|---|
| Collaborative text | CRDT / operational-transform merge; concurrent edits combine |
| Numeric counter | Sum of per-device deltas (PN-counter) — commutative |
| Set / tags | Union of adds minus tombstoned removes (OR-set) — commutative |
| Scalar (status, price) | No safe auto-merge — custom resolver, else last-writer-wins |
| Immutable (created_at) | First write wins; never overwritten |
Definition
Deletes are tombstones
A delete sets `deleted_at` rather than removing the row, so the deletion can propagate to peers that still have the record. Rows are only physically purged after every device has acknowledged the tombstone past its sync cursor — a hard delete before sync is an un-propagatable change that resurrects on the next pull.
Delete versus concurrent modify resolves delete-wins: a tombstone MUST take precedence over a concurrent field modification, and the modification is rejected rather than resurrecting the row. A genuine undelete is an explicit new write with a higher version, never an automatic side effect of a late edit arriving.
Contract
Sync scope
A client does not sync the whole database — it syncs its scope: the set of records its identity is entitled to and interested in. Scope is determined by user identity and tenant (the authorization floor), and optionally narrowed by a time window or explicit subscriptions (record sets the client has asked for). The server MUST enforce the authorization part regardless of what the client requests — a client MAY narrow its scope but MUST NOT widen it beyond what its identity permits, and the server filters every pull accordingly.
When a record leaves a client's scope — reassigned, archived, or aged past the time window — the server MUST push a scope tombstone so the client drops its now-orphaned copy, rather than leaving stale data on the device. A scope tombstone is distinct from a delete: the record still exists server-side; it is simply no longer in this client's scope.
Scope is enforced server-side; client-supplied filters MAY narrow a pull but MUST NOT be trusted to widen it.
Contract
Sync protocol
| Phase | Rule |
|---|---|
| Pull | Fetch in-scope server changes since the stored cursor, one page at a time |
| Resolve | Apply the conflict rule locally (whole-record or field-level) |
| Push | Send dirty records in batches with base_version; clear dirty only on per-record ack |
| Advance | Move the cursor only after a fully-acked round |
Contract
Failure handling
A rejected push is not a lost change. When the server rejects a push, the client MUST keep the record dirty and retry: a conflict rejection routes the record through conflict resolution; a validation rejection that cannot be auto-resolved MUST surface to the user rather than being silently dropped; a transient error retries with exponential backoff.
The acknowledgement path MUST be idempotent. A client that crashes after the server committed a push but before recording the ack will re-send on restart; the server MUST detect the duplicate — by (record id, version) or a client-supplied idempotency key — and treat the re-send as a no-op returning the same ack, so a crash mid-ack never double-applies or double-counts.
| Failure | Handling |
|---|---|
| Push rejected — conflict | Route through conflict resolution; keep dirty; retry |
| Push rejected — validation | Auto-resolve if possible, else surface to the user; never silently drop |
| Push rejected — transient | Retry with exponential backoff |
| Crash during ack | Re-send on restart; server dedupes by (id, version) / idempotency key |
| Partial batch failure | Ack per record; retry only the failed records |
A batch push MUST be acknowledged per record, not all-or-nothing, so one bad record does not force the whole batch to re-send.
Contract
Scalability
Pull operations MUST be paginated. A pull returns at most a page of changes (default 500 records) plus a cursor; the client repeats until the cursor is drained, so a large initial sync streams in bounded pages rather than one unbounded response. The cursor is the server high-water mark from the checkpoint and is opaque to the client.
Push operations SHOULD be batched (default 100 records per request) to amortise round-trips, subject to the per-record acknowledgement rule above. For large datasets, sync deltas rather than snapshots — only records changed since the cursor — and keep large binary blobs in a separate table or object store, synced by reference, so a metadata sync is never blocked behind multi-megabyte payloads.
| Operation | Default | Mechanism |
|---|---|---|
| Pull page size | 500 records | Cursor-paginated; repeat until drained |
| Push batch size | 100 records | Per-record ack; retry failures only |
| Large blobs | By reference | Separate table / object store; sync metadata first |
| Ongoing sync | Delta only | Changes since cursor, not full snapshots |
Invariants this spec guarantees
- Record IDs MUST be collision-resistant and client-generated (UUID or equivalent); two devices do not mint the same ID for different records.
- A push MUST carry its base_version; the server applies it only when base_version equals the current server_version, and otherwise routes it to conflict resolution rather than overwriting.
- Conflict resolution is deterministic and runs identically on server and clients, so peers converge independent of sync order — whole-record by total order, or field-level only where the field type has a well-defined merge.
- Delete-versus-modify resolves delete-wins; a tombstone is never overwritten by a concurrent edit, and rows are not hard-deleted before every device has synced past the tombstone.
- Scope is enforced server-side: a client MAY narrow its scope but MUST NOT widen it, and records leaving scope are pushed as scope tombstones.
- Acknowledgement is idempotent and per-record — a crash mid-ack re-sends safely and a partial batch retries only failed records — so no acknowledged change is lost or double-applied.
Revision history: revised on 24 August 2026 to add separate client/server versioning with base_version conflict detection, whole-record vs field-level merge with per-type commutativity and custom resolvers, server-enforced sync scope with scope tombstones, idempotent per-record failure handling, and pagination/batching/delta-sync for scale. Conformance language follows RFC 2119.