Client Contract
Client Contract
Section titled “Client Contract”Scope and authority
Section titled “Scope and authority”This document is normative for protocol version 3 native clients:
- Swift on Apple platforms
- Kotlin on Android
- React Native through the native Swift and Kotlin clients
The server and its PostgreSQL extension define protocol semantics. Native clients own the local SQLite store, durable local state, scheduling, transport execution, and safe application of server results. An application owns its local SQL reads and writes and its product response to reported outcomes. An application does not own sync conflict rules, cursor rules, scope rules, rebuild rules, or protocol retries.
Protocol version 3 uses the following authoritative boundaries:
- The server assigns scopes and determines scope membership.
- The server assigns schema actions and determines schema compatibility.
- The server assigns opaque row versions and evaluates compare-and-swap conflicts.
- WAL materialization is the only source of pull-visible server changes.
- The native client records local intent, retains it durably, and applies authoritative server results without creating new intent.
- React Native forwards the native contract and contains no second sync engine.
Client types and local boundary
Section titled “Client types and local boundary”Native clients
Section titled “Native clients”Each native client exposes one local SQLite database as the application data store. Application code reads and writes synced tables through local SQL. The client captures those writes locally and later sends protocol mutations. The application does not need a separate upload endpoint or a custom backend write contract for synced CRUD.
The native client also owns:
- local SQLite initialization and schema application
- row-level local change capture for registered synced tables
- durable mutation identity and queue storage
- sealed batch creation and exact request replay
- push, pull, rebuild, and connect scheduling
- retry and backoff for retryable transport and availability failures
- server-result application, provenance, checksum, and cursor state
- durable process-death recovery
- typed status, errors, and sync events
React Native
Section titled “React Native”React Native is a typed bridge over the native client for the current platform. The bridge exposes the native database and sync engine through one logical SDK surface. JavaScript does not implement mutation queues, request fingerprints, batch replay, server-version comparison, cursor handling, scope provenance, checksum calculation, rebuild paging, schema compatibility, or retry classification.
The iOS bridge delegates to Swift and the Android bridge delegates to Kotlin. Both native implementations consume the same protocol 3 rules. A bridge call does not create a second local queue or a second copy of durable sync metadata.
Server-defined scope model
Section titled “Server-defined scope model”Clients send only server-assigned scope identifiers and the opaque cursors issued for those scopes. A client never sends an arbitrary replication predicate, creates a scope instance, or changes a scope’s membership rule.
Scope assignment, scope_set_version, scope membership, and membership
generations are server authority. A client stores them for continuation and
applies the server’s assignment delta. It does not infer assignment from rows
already present in SQLite.
Local database and durable metadata
Section titled “Local database and durable metadata”The local database contains three categories of state:
- Synced application tables.
- Application-owned local-only tables.
- Synchro metadata tables that are not part of the application domain model.
Synchro metadata is stored in durable SQLite state. It includes, at minimum:
- the registered
client_idand current server-issuedclient_generation - the exact applied schema version and schema hash
- the authoritative
scope_set_version - assigned scope records and scope removal state
- one opaque incremental cursor per scope
- one verified authoritative checksum per healthy scope
- scope membership provenance for composed local tables
- per-row server-version and deletion metadata
- mutation records, authored payloads, queue state, batch state, and outcomes
- retry attempt and next-attempt state
- rebuild attempt, page, session, and finality state
- portable seed continuation receipts and their validation state
- typed client status and durable error state where required for recovery
The client keeps protocol bookkeeping in side metadata rather than changing the meaning of application columns. A server version can be stored beside a row even when the local table has no corresponding timestamp column.
Every durable metadata transition is atomic with the local application state that it describes. A process may stop between transactions, but it cannot leave a committed row apply without its corresponding queue, provenance, checksum, cursor, schema, or rebuild metadata transition.
Durable mutation identity
Section titled “Durable mutation identity”Client and mutation identifiers
Section titled “Client and mutation identifiers”batch_id and mutation_id are non-nil UUIDs in canonical lowercase,
hyphenated text form. A rebuild_id has the same UUID requirements.
The server scopes a batch identity by:
(authenticated_user_id, client_id, batch_id)The server scopes a mutation identity by:
(authenticated_user_id, client_id, mutation_id)The authenticated identity comes from the adapter authentication boundary. A client does not place a substitute user identity in the protocol request.
The native client generates each UUID once and persists it before the request can be transmitted. A mutation UUID identifies one authored intent for the lifetime of that client identity. It is never assigned to different content, reused for a later local action, or regenerated because a response was lost. Mutation identity is unique across all batches for the scoped client.
The client persists enough identity state to survive restart, backup restore handling, network loss, and response loss. A retired client identity is not reused for a new installation.
Immutable authored payloads
Section titled “Immutable authored payloads”Every queued mutation retains its complete authored representation, including:
mutation_id- logical
table_id - one typed primary-key value
operation, which isinsert,update, ordeleteauthored_schemaversion and hashbase_versionwhen the operation requires one- required
client_version - every authored writable field ID and its typed value
- field presence, including the distinction between absent and SQL
null - durable local ordering metadata
The authored payload contains logical table and field IDs from the immutable schema manifest. It does not depend on mutable field names or current local column order. Primary-key, generated, server-owned, creation-time, update-time, deletion-time, and version fields are not writable mutation fields.
The client does not remove an unknown, removed, renamed, or incompatible field from an authored payload. It does not rewrite a table ID, field ID, primary key, operation, client timestamp, or authored schema to fit a new manifest. A retained payload remains inspectable until explicit policy clears it.
Before a mutation enters any sealed batch, reconciliation of an earlier
accepted mutation for the same row may refresh only that pending mutation’s
server-owned base_version. This preserves its UUID and authored intent. Once
the mutation is sealed or transmitted, its base version and all other request
content are immutable and it is never rebased in place.
The operation presence matrix is fixed:
| Operation | base_version |
columns |
client_version |
|---|---|---|---|
insert |
absent | authored writable fields, possibly empty | required |
update |
required opaque token | nonempty authored writable patch | required |
delete |
required opaque token | absent | required |
An insert that authors no writable field is a key-only create with empty
columns. The client captures it only when the insert has its own capture
context, because an insert without that context has unknown authored fields.
The client rejects an empty update patch, an unknown operation, a missing required value, a present forbidden value, or a duplicate mutation ID within one batch before sending it. The server remains authoritative for schema, policy, value, and row state validation.
Local capture and offline writes
Section titled “Local capture and offline writes”For a write to a synced table, the client commits the application row change, the capture record, and the durable mutation identity in one local SQLite transaction. If that transaction fails, neither the application write nor the queued intent is committed.
Offline writes before the first successful connect use this same transaction
and this same durable queue. They are not stored in a temporary pre-connect
queue and are not discarded when schema or scope negotiation begins.
Synced-table writes before the first successful connect require a locally verified protocol 3 manifest supplied by an application bundle, a verified schema-only seed, or a verified portable-data seed. The client verifies that manifest’s canonical hash before creating synced tables or authored intent. A fresh empty client with only the zero-version sentinel and no verified manifest can accept local-only writes but cannot identify synced tables or field IDs and therefore does not expose synced-table writes until connect installs a manifest.
The local capture layer records explicit protocol operations. It does not turn an application update into an implicit upsert, turn a delete into a resurrection, or create a second mutation for a server-originated apply.
Pre-wire same-row dependencies
Section titled “Pre-wire same-row dependencies”Every local action keeps an immutable intent record and stable mutation ID. Before any action in one same-row chain is sealed, the client can normalize the chain into a new linked wire mutation:
- insert plus updates becomes one insert with the final authored fields;
- insert plus delete becomes no server mutation;
- update plus updates becomes one update from the original known server base;
- update plus delete becomes one delete from that original base.
A normalized mutation takes the durable local order of its first source. Thus normalization does not move a mutation after a later capture of another row. For example, a parent insert stays before a child insert that the application captured after it.
Original records become durable inspectable superseded_before_send or
cancelled_before_send outcomes linked to the normalized mutation or
cancellation. A delete plus update is not normalized because protocol version 3
has no resurrection.
When a predecessor is already sealed or transmitted, a later update or delete
without a usable server version remains a durable dependent intent and is not
sent. If the predecessor is accepted, the client makes the unsealed dependent intent
sendable with the returned opaque version. If the predecessor conflicts, is rejected,
or exceeds a push limit, the dependent intent becomes inspectable
blocked_by_predecessor. These local
states are never encoded as push operations and never fabricate a base version.
Sealed immutable batches
Section titled “Sealed immutable batches”Batch creation
Section titled “Batch creation”A batch contains the bound client_id, positive client_generation, one
batch_id, the exact request schema reference, and a nonempty ordered list of
mutations. The client persists the complete batch before its first network
transmission.
Sealing fixes all of the following:
- batch UUID
- request schema version and hash
- mutation order
- mutation UUIDs
- complete authored payloads
- all field presence and null distinctions
- local ordering represented by the request
The server computes batch and mutation SHA-256 fingerprints from the exact normalized RFC 8785 arrays in ADR 002. The client preserves canonical wire values, absent-versus-null presence, bytewise field ordering, request mutation order, schema identity, and opaque versions for that computation. Raw HTTP bytes, serializer-specific object order, or an authored field omitted during serialization are not a valid replacement for the sealed content.
After sealing, the client cannot add, remove, reorder, or edit a mutation in that batch. Every retry of that batch uses the same identity and the same canonical content. A request-level failure does not authorize the client to change the sealed request.
An unchanged queued mutation can be carried into a new sealed successor batch after schema reconciliation or client-generation renewal with its original UUID and complete authored payload. The successor has a new batch UUID, the current request schema and generation, and only mutations whose authored fingerprint is unchanged. The original sealed batch remains an immutable record and is never sent again after a first-execution generation rejection. A mutation whose authored content, table, fields, operation, base version, or schema identity changes receives a new mutation UUID and remains linked to the original intent for inspection.
Push limits at sealing
Section titled “Push limits at sealing”The client applies the push limits of the wire protocol when it seals a batch. It measures these values:
- the UTF-8 octets of the exact request body that it stores and sends
- the octets of the RFC 8785 form of that body
- the octets of the RFC 8785 form of each normalized mutation in ADR 002
- the authored column count of each mutation
The client measures the batch envelope with the protocol maximum for
client_generation and schema.version. Thus a successor batch after
generation renewal or schema reconciliation also stays within the limits.
The client selects pending mutations in durable local order. It stops before the first mutation that causes a nonempty batch to exceed a request limit. That mutation starts the next batch.
The client does not seal or send a mutation that exceeds a mutation limit. It
also does not seal or send a mutation that exceeds a request limit alone in an
empty batch with the reserved envelope. The
mutation gets the durable local state exceeds_push_limit. It keeps its
authored content and stays inspectable. Its same-row dependents become
blocked_by_predecessor. The client then continues with the next pending
mutations. exceeds_push_limit is not a push operation and is not a server
outcome.
The client applies these checks only when it seals a new batch. A loaded
sealed batch keeps its exact stored bytes and is sent before any new batch.
A client release without these checks could seal a batch above a request or
mutation limit. The server rejects each send of that batch with
400 invalid_request, because the client does not change sealed bytes. This
release defines no recovery for such a batch.
If the reserved envelope of a new batch alone exceeds a request limit, the
client seals no new batch. It records a nonretryable invalid_request blocking
failure with the recovery action none. The failed seal transaction preserves
each pending mutation’s state, authored content, and dependencies. It does not
undo queue normalization that committed before sealing. This local configuration failure has no
HTTP status and is not a server outcome. It does not change, discard, or block
the replay of an existing sealed batch. A client with no pending mutation seals
no new batch and records no failure.
Exact replay
Section titled “Exact replay”The client retries a sealed batch after a lost response using the same batch UUID, mutation UUIDs, order, schema, and authored values. It never reconstructs the request from current row state for a retry.
For an already completed batch with matching content, the server returns the original HTTP status and exact canonical response body. The response retains the original server time, outcome order, rows, versions, codes, and messages. The server performs no source write, policy hook, version allocation, scope materialization, or mutation reclassification during exact replay.
If the server reports idempotency_conflict, the client does not retry with
the same batch identity or attempt to repair the content. It enters an
explicit non-retryable error state while retaining the sealed request and its
identities for inspection.
HTTP 429 and 503 responses contain no mutation outcomes. The client retains the
sealed batch unchanged and retries it after the prescribed delay. A request
level schema_mismatch also retains the sealed batch unchanged. A new batch
identity is required for a reconciled request schema.
Atomic write groups
Section titled “Atomic write groups”atomicWriteTransaction runs an application write transaction whose synced
mutations the server applies all together or not at all.
| Surface | API |
|---|---|
| Swift | func atomicWriteTransaction<T>(_ block: (ApplicationTransaction) throws -> T) throws -> T on SynchroClient |
| Kotlin | fun <T> atomicWriteTransaction(block: (ApplicationTransaction) -> T): T on SynchroClient |
| React Native | atomicWriteTransaction<T>(fn) on SynchroClient, backed by the TurboModule method beginAtomicWriteTransaction(): Promise<string> |
The API opens the transaction through the same path as writeTransaction, so
nesting behavior is identical to writeTransaction.
The client captures a group with these rules:
- At transaction start, the client writes a new lowercase UUID to
_synchro_metawith keyatomic_group_id. - Each capture trigger copies that value into the nullable
atomic_group_idcolumn of_synchro_pending_changes. - Before commit, the client deletes the
_synchro_metakey. A rollback removes it. - A group that captures no rows commits as an ordinary empty write.
Before commit, the client validates the group with these rules in this order. A failure throws a typed error and rolls back the local transaction.
- No
deleteentry for a row is followed by a later entry for the same row in the group. The failure reason isdeleteFollowedByWrite. - The client runs chain normalization on the group rows only. A chain merges
only entries with an equal
atomic_group_id, and NULL equals NULL. - The normalized group has at most 1000 entries. The failure reason is
tooManyMutations. - Each normalized mutation is within the mutation limits of “Push limits at sealing”:
at most 65,536 octets in the canonical normalized mutation form, and at
most 256 authored columns. The failure reason is
mutationTooLarge. Without this rule, one member could getexceeds_push_limitalone when the client seals the batch. - The worst-case request body and its RFC 8785 form are each at most
1,048,576 octets. The failure reason is
requestTooLarge.
The worst-case request uses these values:
| Member | Value used for measurement |
|---|---|
client_id |
The real client ID |
client_generation |
9007199254740991 |
batch_id |
A 36-octet UUID |
schema.version |
9007199254740991 |
schema.hash |
A 64-octet lowercase hexadecimal string |
atomic |
true |
base_version, when known |
The real value |
base_version, when unknown |
A 64-octet placeholder string |
The typed error is SynchroError.atomicGroupInvalid(reason: AtomicGroupInvalidReason) in Swift and SynchroError.AtomicGroupInvalid(reason: AtomicGroupInvalidReason) in Kotlin. In React Native, the rejected promise has
code atomic_group_invalid. AtomicGroupInvalidReason has exactly the four
reasons named above.
The batch selector applies these rules:
- It reads eligible entries in local order.
- If the first eligible entry has no group, the batch is the leading run of eligible entries with no group, up to the batch size. The run stops at the first grouped entry.
- If the first eligible entry has a group, the batch is the complete
normalized group with
atomic: true. The selector selects it only if every member is unsealed, has no unresolved dependency, and is an insert or has a base version. Otherwise it selects no batch, and the group waits. - An atomic batch ignores the configured batch size.
- When the blocking rule marks one unsent group member
blocked_by_predecessor, the same local transaction marks every unsent member of that groupblocked_by_predecessor. - The sealed request includes
atomic, so each retry sends identical content.
A failed group uses the ordinary outcome paths. The client adds no revert path.
An atomic_batch_rejected outcome follows the terminal path, so the local row
stays and inspectRejectedMutations reports the mutation. A failing conflict
follows the conflict path. It applies server_row and reapplies later local
intent. After a failed group with a conflict, local state therefore holds the
server row for the conflicting member and local values for every other member.
Exact-ID reconciliation
Section titled “Exact-ID reconciliation”Outcome partition
Section titled “Outcome partition”A successful first push execution returns HTTP 200 and accounts for every
request mutation exactly once. Each mutation ID appears in exactly one of
accepted or rejected, in relative request order within its array. No
mutation is omitted, duplicated, or present in both arrays.
The client indexes reconciliation by the scoped mutation_id, not by array
position, row order, local timestamp, or current local state. It verifies that
the returned table and typed primary key match the queued mutation and that
every outcome carries an exact immutable outcome_schema before
applying an outcome. An unknown, duplicate, missing, or mismatched outcome is
a non-retryable local protocol error and does not advance queue state.
The client retains immutable manifests needed by unresolved sealed batches and
mutation ledgers. It verifies each row and checksum under outcome_schema, not
the current request schema. A historical replay is stored exactly. If its row
cannot be projected safely into the current local schema, the client preserves
later local intent, updates no incompatible row in place, and marks every
affected assigned scope for current-schema rebuild.
Accepted outcomes
Section titled “Accepted outcomes”An accepted outcome contains the new opaque server_version and reports the
row state after its push unit. If the row exists after the unit, the outcome
contains the complete canonical server_row or source tombstone and the
matching row_checksum. If the row does not exist after the unit, the outcome
contains no row and no checksum. This absence applies to every operation. A
fence-only delete is one example. A later write in the same unit, such as a
cascade delete in an atomic group, can also remove an inserted or updated row.
The client verifies any row checksum, applies the canonical row or absence, stores the returned opaque server version, records the accepted outcome, and removes the mutation from the sendable queue in one local transaction. A completed mutation is not applied again when its exact replay response is received.
If later unresolved mutations for the same typed row exist, reconciliation updates the authoritative server base and then reapplies those unresolved local intents in their durable local order. An unsealed successor refreshes only its server-owned base precondition. A sealed or transmitted successor remains unchanged and receives its own authoritative server outcome. The client preserves each local projection and UUID without changing authored operation, fields, values, schema, timestamp, or local order.
Conflict and terminal outcomes
Section titled “Conflict and terminal outcomes”A row conflict is a per-mutation HTTP 200 outcome. The server evaluates the
authoritative row state and returns conflict with one of the server-defined
codes, including version_conflict, row_already_exists, row_deleted, or
row_not_found as applicable. A terminal semantic rejection uses
rejected_terminal and a terminal code such as policy_rejected,
validation_failed, table_not_synced, schema_incompatible, or
atomic_batch_rejected.
A terminal rejection has no row and no server version. It does not describe
absence or deletion. This rule also applies to policy_rejected when source
row security hides a live row or denies a write. The client does not
remove the local row or replace its stored server version because of a
terminal rejection.
When an outcome includes a canonical row or tombstone, matching checksum, and server version, the client verifies and applies that state in the same reconciliation transaction. When the outcome describes true absence, the client removes only the local materialized state justified by the server result and stores the outcome. A fence-only delete retains the server version metadata needed for authoritative reconciliation.
If later unresolved mutations for the same typed row exist, this transaction updates the authoritative server base and reapplies those local intents in durable local order. A conflict or terminal rejection never authorizes an automatic base-version refresh. The local projection does not change the rejected outcome, its authored payload, or the server’s conflict decision.
The original authored mutation and its exact outcome remain inspectable after a conflict or terminal rejection until explicit client policy clears them. The rejected mutation is excluded from automatic retry. There is no per- mutation retryable outcome in protocol version 3.
Successor intent preservation
Section titled “Successor intent preservation”The client never treats a rejected mutation as resolved by deleting its authored intent. A later application write creates a new mutation UUID even when it targets the same row. A schema mapping, field replacement, user decision, or other changed intent also creates a new mutation UUID. The original payload, identity, authored schema, base version, timestamp, and outcome remain separately inspectable.
The client does not automatically merge, rebase, choose a winner, retry a conflict with a new base version, resurrect a deleted row, or convert one operation into another. Any successor content is explicit application intent captured through local SQL or an explicit SDK operation that uses the same capture rules.
Pre-wire normalization and cancellation are not server conflict resolution. They preserve every local action record and represent the net unsent row intent before any server identity or fingerprint exists.
Opaque versions and diagnostic timestamps
Section titled “Opaque versions and diagnostic timestamps”server_version is a nonempty, server-generated equality token. A native
client stores and forwards it byte-for-byte as an optimistic precondition and
reconciliation value. It does not parse, sort, increment, compare as a
timestamp, generate, or derive meaning from it.
base_version has exactly one client meaning: update and delete require
byte-for-byte equality with the server version from which the local intent was
authored. Insert has no base_version. The client does not perform a local
version comparison as a substitute for server compare-and-swap.
client_version is a required client-authored RFC 3339 UTC timestamp that
identifies the local intent for diagnostics. It is not a row precondition,
server version, source-row timestamp, ordering key, conflict input, or
last-write-wins value. Client clock skew, equal timestamps, and future
timestamps do not change server conflict authority. The client never uses
client_version to select or overwrite a server version.
The client does not treat an application updated_at value as a protocol
version. Server-owned timestamp and version fields remain server authoritative.
Connection and schema actions
Section titled “Connection and schema actions”Connect
Section titled “Connect”startSync() initializes local metadata and enters the client state machine.
The native client calls POST /sync/connect with the current client identity,
client generation when known, protocol version, schema reference, scope-set
version, assigned scope state, and portable seed receipts when present.
Connect is the only operation that evaluates a historical schema reference. Protocol version 3 uses this exact fresh schema sentinel:
{ "version": 0, "hash": ""}The server returns one authoritative schema action:
| Condition | Action | Client behavior |
|---|---|---|
| Exact current schema | none |
Continue with the current local schema. |
| Server-confirmed fresh identity using the fresh sentinel, or known Class 2 lineage | replace |
Apply the inline immutable manifest and update schema metadata. |
| Fresh sentinel used with a generation, reset, or durable prior client identity | HTTP 400 invalid_schema_reference |
Preserve all local state and enter an explicit non-retryable connection error. |
| Known Class 3 lineage with affected assigned scopes | rebuild_local |
Apply the inline immutable manifest, then rebuild only the returned affected scopes. |
| Known Class 3 lineage with no affected assigned scope | replace |
Apply the inline immutable manifest; no empty rebuild action is emitted. |
| Unknown lineage or Class 4 boundary | unsupported |
Enter an explicit upgrade-required error state and stop normal sync until an upgraded client explicitly requests schema reset. |
Protocol version 3 never emits fetch. The connect response includes the
selected manifest when an action requires it. The client does not call a
separate schema endpoint, invent a compatibility result, or continue with an
old manifest after unsupported.
After application upgrade or an explicit operator-approved recovery, a
continuing client can reconnect with schema_reset: true, its current client
generation, and the schema reference actually installed locally. A valid reset
returns the complete current manifest. The client preserves local-only data,
all authored mutation and sealed-batch records, and inspectable outcomes while
replacing synced materialization. It rebuilds all assigned scopes when any are
assigned. A client never sets reset automatically merely because a lineage is
unknown.
Schema DDL, schema metadata, authored-queue compatibility state, and any durable migration checkpoint are committed as one local migration transition. An interrupted migration leaves a durable migration marker and blocks normal sync until the same migration resumes or enters the explicit error state.
The connect response’s scope_cursor_updates map applies only to existing
assignments. During Class 2 or Class 3 migration, the client installs each
non-null server-issued current-schema replacement atomically with successful
target-manifest activation, without parsing either token. A null update marks
that scope rebuild-required. The client never carries an old-schema cursor into
pull, installs a replacement before target DDL and metadata are durable, or
treats cursor replacement as assignment change or server acknowledgement.
Exact current schema gate
Section titled “Exact current schema gate”Push, pull, and rebuild requests use exactly the current immutable schema
version and hash. A stale or mismatched request receives HTTP 422
schema_mismatch with current and received schema references and no partial
operation. The client reconnects, applies the returned schema action, and
retries using a new sealed batch where a new batch is required.
The request schema changes during reconciliation, but each queued mutation’s
authored_schema remains the immutable manifest under which it was captured.
The server applies an older authored payload only when its table and field IDs
remain compatible with the current manifest.
Scope assignment changes
Section titled “Scope assignment changes”The client applies the server’s assignment delta atomically with the returned
scope_set_version.
- An added scope has no usable cursor and enters rebuild state.
- A removed scope loses local provenance and scope metadata. This cleanup creates no client mutation and no synthetic tombstone.
- A row that remains justified by another scope remains in the local table.
- A row that has no remaining valid provenance and no unresolved local authored intent is removed from server-authoritative local materialization without creating a local delete mutation.
- If unresolved local authored intent addresses the row, assignment cleanup removes only server scope provenance and continuation metadata. It preserves the mutation’s application-visible local row state in the local-intent layer until push-first reconciliation produces an explicit outcome or application policy resolves it.
The client never invents an assignment, advances scope_set_version from a
local observation, or uses a removed scope’s cursor for another scope.
Pull reconciles assignment, so a client that knows no scope still pulls in
each normal sync cycle. This pull has an empty scopes map. A new assignment
then arrives in that response without a reconnect.
scope_set_version advances only from an authoritative assignment response
that changes the assigned scope set. The client rejects a future value,
preserves the current value when the assignment set is unchanged, and commits
the accepted value with the corresponding assignment delta.
Sync ordering
Section titled “Sync ordering”Push before pull
Section titled “Push before pull”The normal native sync loop is:
- Open and validate local metadata.
- Connect and execute the authoritative schema action.
- Push every eligible sealed batch in durable queue order.
- Reconcile each successful push response by exact mutation ID.
- Rebuild scopes with no usable cursor or an explicit rebuild requirement.
- Pull incremental changes for assigned scopes. Send this pull also when no
scope is assigned. Its
scopesmap is then empty. - Apply changes, assignment updates, checksums, and cursors atomically.
- Rebuild scopes listed by pull, then continue incremental pull.
- Publish final typed status and events.
Push precedes pull whenever both operations are ready. This provides local read-your-own-write reconciliation before a later WAL echo arrives. A pull echo is authoritative server state and is not a second local mutation.
The client drains has_more pages before declaring incremental work current.
A rebuild is scope-local and does not permit the client to advance an
unrelated scope.
WAL lag and capture_pending
Section titled “WAL lag and capture_pending”An accepted push can commit its source row and opaque version before the WAL
worker materializes the corresponding pull-visible effect. If a client has an
accepted push fence that is not yet satisfied by exact WAL materialization or
activated verified reset-baseline coverage, pull or rebuild returns HTTP 503
with code capture_pending and retryable: true.
On capture_pending, the client:
- retains all presented pull cursors and scope metadata unchanged
- installs no new cursor, checkpoint, checksum, or assignment progress from the failed page
- retains the accepted canonical local row and its server version
- does not synthesize a pull change, duplicate the push, or create a rebuild session from state older than the accepted write
- schedules the same pull operation through native backoff
The client does not interpret capture lag as a conflict or as proof that the accepted write was lost. A later successful pull observes the WAL-materialized effect in normal stream order, and a later rebuild snapshot includes it when it falls at or before the rebuild boundary.
Incremental pull application, checksums, and cursors
Section titled “Incremental pull application, checksums, and cursors”Response validation
Section titled “Response validation”The client treats cursors as opaque strings. null means no usable resumable
state and requires rebuild. A non-null cursor is stored exactly as returned and
is never parsed, sorted, truncated, decoded, or compared lexically.
Before changing local state, the client validates the complete pull response:
- every change has one assigned scope, logical table, typed primary key, and operation
- every row-bearing upsert or delete has an opaque server version and a valid canonical row checksum bound to that version
- every row primary key matches its separate typed primary key
- every row contains the complete current manifest field set
- scope updates and cursor deltas refer only to server-assigned scopes
- a terminal page has a checksum for every active scope after its assignment delta and no missing or extra checksum key
- an intermediate page omits the terminal checksum map
- a scope listed for rebuild receives no incremental cursor in that response
A malformed, incomplete, forged, misbound, or semantically inconsistent response is a non-retryable local protocol error. It produces no partial row, provenance, checksum, cursor, or assignment commit.
Atomic page application
Section titled “Atomic page application”The client applies one successful pull page in one SQLite transaction. The transaction includes:
- authoritative upserts and deletes
- scope provenance additions and removals
- local cleanup for scope removal or scope loss
- the returned assignment delta and
scope_set_version - all eligible per-scope cursor updates
- terminal checksum verification results and healthy checksum metadata
For a row-bearing upsert, the client recomputes the canonical row digest from the exact manifest, logical IDs, typed values, null state, and row identity before writing. For a row-bearing delete tombstone, it performs the same verification. A rowless delete removes only the server-supplied scope provenance and never creates a local mutation.
A scope cursor is installed only after every corresponding change for that scope has been applied successfully. A failed transaction installs no cursor. An older valid cursor never moves local progress backward. The client does not manufacture a cursor from a row key, timestamp, page count, or local clock.
Issuing a cursor does not acknowledge it on the server. The client presents a successfully installed cursor in a later pull request, and only that later request can acknowledge the presented position for that scope.
Terminal checksum verification
Section titled “Terminal checksum verification”When has_more is false, the client recomputes the canonical scope digest for
every active scope named by the terminal checksum map, including scopes with no
row change in the page. The digest uses the protocol 3 typed SHA-256 grammar,
the exact schema hash, scope ID, cardinality, sorted row identities, and row
digests. A cursor alone never marks a scope healthy.
For each scope whose recomputed digest matches, the client commits the verified checksum and eligible cursor with the page transaction. If one scope digest differs, that scope’s resumable state is invalidated and marked for rebuild; the client does not install that scope’s terminal cursor or checksum. Other scopes retain only state that was independently applied and verified.
A missing, malformed, incomplete, or extra terminal checksum key fails the whole page and advances no cursor. A row digest or checksum mismatch never causes the client to accept unverified state.
Durable rebuild state
Section titled “Durable rebuild state”Rebuild attempts and session records
Section titled “Rebuild attempts and session records”A rebuild is required when a scope has no usable cursor, when pull or connect
requests it, when schema action rebuild_local names it, or when local
checksum or provenance validation invalidates its state. First synchronization
uses this same rebuild contract.
With default configuration, a server-requested rebuild starts automatically without application approval or application-owned rebuild orchestration.
At the start of an attempt, the client marks the scope as rebuilding, pauses incremental advancement for that scope, and resets only that scope’s local materialization provenance. Rows with another valid scope provenance and all local-only data remain. A row left with no valid provenance is removed without creating a mutation only when no unresolved authored local intent protects its application-visible local row state. Protected intent remains in the local-intent layer outside attempt-scoped server provenance until reconciliation or explicit application policy resolves it. The reset and active rebuild record commit together before the first page request.
For each scope, the client stores a durable rebuild record containing:
- the scope ID and target schema reference
- one immutable canonical
rebuild_idUUID for the attempt - the bound client generation and membership, retention, and stream lineage references
- the fixed page limit for the attempt
- the exact last request page token, including the initial null token
- the exact next page token returned by the server
- the server session binding represented by the continuation token
- durable identities for pages already applied
- whether the attempt is active, awaiting retry, finalizing, complete, or restart-required
- final scope cursor and checksum only after verified finality
The client stores tokens and session values as opaque data. It does not derive an ordinal, row key, snapshot boundary, or scope identity from token text.
The client generates and persists rebuild_id before the first request. A
response loss, process death, 429, or 503 reuses the same rebuild ID and
continuation token. A new rebuild ID is created only for a new attempt after
rebuild_restart_required, local invalidation, or explicit restart policy.
Immutable server session and page token
Section titled “Immutable server session and page token”The server binds every rebuild continuation to its immutable staged snapshot, client, generation, scope, schema, membership, retention, stream, snapshot boundary, page position, page size, and expiry. The client sends the exact opaque token returned for the prior page and never sends a lexical row key as a continuation.
The first null-cursor request creates or reuses the server’s unexpired session
for that rebuild_id. Intermediate and final pages come from that immutable
server snapshot. A token expiry or generation invalidation returns HTTP 409
rebuild_restart_required; the client discards only that scope’s continuation
and starts a new scope-local attempt. A malformed or misbound token returns
HTTP 400 invalid_request and is not repaired by changing token text.
The server session expires exactly 24 hours after creation. Page replay and token issuance do not extend that expiry. The client treats an expired session as restart-required and does not retain it as resumable state.
Idempotent page apply
Section titled “Idempotent page apply”Every rebuild page is applied in one local transaction. The transaction contains the page identity, all row and provenance writes, and the next page state. A page identity binds the rebuild ID, the exact request token, the associated server session, and the canonical page content.
If the same page is received again, the client finds its durable page record, verifies that the content is identical, and returns the previously committed local result without applying rows or provenance twice. A changed response for an existing page identity is a non-retryable integrity error. A process death before commit leaves no page record and no page apply. A process death after commit leaves both the row apply and next-page state durable.
An intermediate page stores its returned continuation token only with its committed row and provenance apply. It does not install a final cursor or checksum. The client then requests the next page with that exact token and fixed page limit.
Rebuild finality
Section titled “Rebuild finality”The final page is applied in one transaction that:
- Applies all final records and verifies every row checksum.
- Prunes stale provenance for that scope.
- Removes local rows with zero remaining valid provenance only when unresolved authored local intent does not protect their application-visible state.
- Recomputes the scope digest from the resulting local state.
- Verifies the server checksum.
- Installs the final opaque incremental cursor and verified checksum for that scope.
- Clears the active rebuild continuation and records completion.
No final cursor or checksum is installed before all pages, pruning, and digest verification succeed. A digest mismatch leaves the scope without healthy final state and schedules a new rebuild. Rebuilding one scope never changes an unrelated scope checkpoint, cursor, checksum, provenance edge, assignment, or rebuild session.
The final rebuild cursor is not a server checkpoint acknowledgement. The client presents it in a later successful pull request for that scope. Writes after the immutable rebuild boundary arrive through normal incremental pull.
Schema-incompatible queued intent
Section titled “Schema-incompatible queued intent”The server evaluates a queued mutation’s preserved authored table and field
IDs against the current immutable schema. If the intent cannot be represented,
the push response contains an HTTP 200 terminal outcome with code
schema_incompatible, retryable: false, authored_schema, current_schema,
and bytewise-sorted incompatible_field_ids.
On this outcome, the client:
- stores the complete original mutation and exact outcome durably
- marks the mutation blocked and excludes it from automatic retry
- retains the authored schema, base version, client timestamp, field values, field presence, operation, and local order
- exposes the rejection through typed inspection and event surfaces
- does not drop a removed field, guess a rename, change a field ID, or replace the authored schema
The client does not require or invent a server row or server version for a
schema_incompatible outcome. The retained authored payload is the source of
truth for later explicit application reconciliation.
An application-created replacement is a successor mutation with a new UUID. It is not folded into or substituted for the incompatible mutation. The old intent remains inspectable until explicit policy clears it.
Portable seed receipts and assignment validation
Section titled “Portable seed receipts and assignment validation”A portable seed is a verified warm cache for server-declared portable scopes. It is not an authorization grant and it is not a second sync mode. Seed export contains no user-scoped data and does not accept a user JWT as seed authority.
A seed artifact contains the immutable schema reference, only the declared portable scope set, complete typed rows, opaque server versions, row checksums, scope checksums and cardinalities, and one opaque seed continuation receipt for each bundled scope. The client stores the receipt exactly as supplied. It does not use a seed receipt as a runtime cursor or infer portability from a row’s presence.
At the first authenticated connect, the server validates each receipt’s integrity and its schema, scope, registry, membership, retention, stream, boundary, cardinality, and checksum bindings. It also validates that the authenticated client is currently assigned the scope. For an assigned and valid receipt, the server issues a normal user-bound, client-bound incremental cursor at the seed boundary. Seed installation already stored every verified seeded row’s opaque version as local compare-and-swap base, so offline update or delete capture has an exact precondition before connect. Authenticated receipt validation installs only the assignment and runtime cursor; possession of a row version grants neither scope access nor write authority.
For an unassigned seed scope, the client removes seed provenance and unchanged zero-provenance cache rows and does not request that scope. It preserves pending authored mutations and their local row state for normal server authorization and an explicit mutation outcome. For an assigned scope with a stale or unverifiable receipt, the client discards the receipt’s continuation state and rebuilds the scope only after pending intent follows normal push-first scheduling. Seeded rows remain local cache data and never authorize access to a scope.
Seed installation is atomic and verified before publication. A valid seeded database has no partially installed cursor, no pending queue created by seed rows, complete provenance, verified schema metadata, and no server-apply echo.
Errors and backoff
Section titled “Errors and backoff”Strict dispatch
Section titled “Strict dispatch”The client handles the following protocol dispatch without substituting a different outcome:
| HTTP | Code or condition | Client handling |
|---|---|---|
| 400 | invalid_request or invalid_schema_reference |
Non-retryable protocol error. Preserve unresolved local state. |
| 401 | auth_required |
Non-retryable authentication error. Enter explicit error state. |
| 409 | idempotency_conflict or client_retired |
Non-retryable identity error. Preserve the sealed request and surface it. |
| 409 | client_generation_expired |
Reconnect, accept the new generation and assigned rebuild state, then resume through the normal loop. |
| 409 | rebuild_restart_required |
Discard only the invalidated rebuild continuation and start a new rebuild ID for that scope. |
| 422 | schema_mismatch |
Reconnect and apply the authoritative inline schema action. Do not edit the sealed batch. |
| 426 | upgrade_required |
Non-retryable protocol or runtime upgrade error. Stop normal sync. |
| 429 | retry_later |
Retry the exact interrupted request after Retry-After through native backoff. |
| 500 | sync_integrity_failure |
Non-retryable integrity error. Preserve local state and enter explicit error state. |
| 503 | temporary_unavailable or capture_pending |
Retry the exact interrupted request after Retry-After through native backoff. |
The adapter’s error body, status, and retryable value are authoritative. A
client does not convert a per-mutation conflict into HTTP 409, convert a
terminal rejection into a retry, or invent a server_retryable mutation
outcome. A malformed response, invalid checksum, invalid state transition,
unknown operation, or impossible local metadata state is a non-retryable local
contract error.
Native backoff
Section titled “Native backoff”The native SDK owns retry scheduling after transport timeouts, connectivity
loss, HTTP 429, and retryable HTTP 503. It honors a valid Retry-After value
as the minimum delay and uses bounded exponential backoff with jitter for
repeated failures. The next retry time and attempt class are durable retry
metadata.
Pull and rebuild page limits are positive integers within the configured server maximum. The client rejects an invalid local limit before transmission and never replaces zero, negative, non-integer, or over-limit values with a default or a clamped value.
Retryable failure changes only retry bookkeeping. It does not clear or rewrite
queued mutations, sealed batches, presented cursors, rebuild tokens, schema
references, provenance, checksums, or final state. The SDK retries the same
operation and request identity. The host application does not provide retry
timers, connectivity supervisors, or repeated startSync() calls.
Every non-retryable failure enters an explicit typed error state except the
three defined recovery dispatches. schema_mismatch and
client_generation_expired automatically transition the interrupted operation
to connecting; rebuild_restart_required starts a new scope-local attempt in
rebuilding. The client does not continue in silent degraded mode. Any other
error requires the explicit recovery transition defined by the state machine.
Process-death guarantees
Section titled “Process-death guarantees”The following boundaries are durable and atomic:
| Boundary | Required result after process death |
|---|---|
| Local SQL write and capture | Both application row and mutation record commit, or neither commits. |
| Batch sealing | A complete immutable batch exists before transmission, or no batch was sent. |
| Lost push response | The sealed request and all mutation identities remain retryable with exact content. |
| Push outcome apply | Canonical row, server version, queue state, and outcome record commit together. |
| Pull page apply | Rows, provenance, assignment delta, checksum result, and cursor commit together. |
| Rebuild page apply | Rows, provenance, applied-page record, and next token commit together. |
| Rebuild finality | Pruning, digest verification, final cursor, checksum, and completion state commit together. |
| Schema migration | Local DDL, schema metadata, and migration progress resume without accepting an old or partial schema. |
| Stop or cancellation | In-flight cancellation leaves all last committed queue, cursor, checksum, schema, and rebuild state intact. |
After restart, the client resumes an incomplete operation from its durable state. It never advances a cursor because a response was received but not applied, installs a final rebuild state because a final page was only partly processed, or deletes an authored mutation because a request was interrupted.
No echo loop
Section titled “No echo loop”The client has an explicit server-apply mode for push reconciliation, pull apply, rebuild apply, scope cleanup, seed installation, and schema-driven materialization. Local change capture is suppressed for writes in this mode.
An applied server row, tombstone, provenance cleanup, or seed row therefore does not create a new mutation, a new local timestamp, or a new batch. The client records the authoritative server version and outcome metadata directly. An incremental WAL echo of an already reconciled push is applied by row identity and server state without producing another client mutation.
Client state and lifecycle
Section titled “Client state and lifecycle”The complete normative client state map and transition rules are in State Transition Summary. An illegal transition is a non-retryable local contract error and does not mutate queue, cursor, rebuild, or schema state.
An empty database and a seeded database both enter local_ready and use the
same connect, schema, push, pull, checksum, and rebuild protocol. A seeded
database is a warm start, not a bypass of server authority.
Public native SDK surface
Section titled “Public native SDK surface”The native SDK exposes a small stable surface with equivalent Swift and Kotlin semantics:
openor initialization of the local SQLite database and metadataquery(sql, parameters)for local readsexecute(sql, parameters)for local writestransaction(...)for application-local transactionsatomicWriteTransaction(...)for an application transaction whose synced mutations the server applies all together or not at allstartSync()to arm the client-owned lifecyclestopSync()to stop network work without clearing durable statesyncNow()to request an immediate cycle through the native schedulergetSyncStatus()for the current typed state and bounded diagnosticsobserveSyncStatus(...)for typed state changesobserveSyncEvents(...)for typed sync eventsinspectRetainedMutationRecords()for all durable queue records, which include local terminal states such asexceeds_push_limitinspectRejectedMutationRecords()for durable terminal outcomes and authored data- an explicit policy operation to clear retained rejected records
The deprecated inspectPendingMutations(), inspectRetainedMutations(), and
inspectRejectedMutations() keep their published signatures and behavior. They
return current records only, and they fail when a legacy record is present.
Each retained record is either a current record or a legacy record. A current record has its table ID, primary-key binding, authored schema, and authored field values. A legacy record comes from a queue that existed before the mutation ledger. It has only the fields that the old queue stored. Its binding fields and authored values are absent, and the SDK does not fill them with placeholder values. A record with only some bindings fails inspection.
Each rejected record is also either a current record or a legacy record. A current rejected record has its exact mutation JSON and rejection JSON. A legacy rejected record comes from a rejection table that existed before the mutation ledger. It has only the fields that the old table stored. Its mutation JSON and rejection JSON are absent. A record with only one of the two JSON values fails inspection.
An application transaction callback must return before the application calls a sync lifecycle method. A client rejects this call before it acquires a lifecycle lock. This rule prevents opposite lock acquisition between application writes and durable lifecycle transitions.
The SDK does not expose a method that lets an application set a cursor, server version, scope assignment, checksum, client generation, rebuild token, or mutation identity for an unrelated intent. It does not require the application to construct HTTP requests for synced CRUD.
Status events include state changes, backoff, schema application, mutation accepted, mutation rejected, rebuild requested, rebuild completed, and typed errors. Mutation events identify the authoritative mutation outcome and its stable ID. They do not expose a client-defined conflict policy.
Typed errors include operation, stable code, retryability, and bounded diagnostic metadata. They do not expose credentials, request bodies, row payloads, tokens, or raw internal authorization details by default.
React Native bridge boundary
Section titled “React Native bridge boundary”The React Native TurboModule exposes the same logical capabilities as the native SDK:
- open and close the native local database
- query, execute, and transact with typed SQL parameters and rows
- start, stop, and trigger sync
- read and observe typed status
- observe typed mutation, schema, rebuild, backoff, and error events
- inspect pending and rejected mutations through native inspection APIs
Bridge serialization preserves native typed SQL values, null presence, typed rows, UUID strings, opaque versions, opaque cursors, checksums, and typed error codes. The bridge does not decode or sort server tokens.
JavaScript cannot provide an alternate queue, retry loop, conflict resolver, scope selector, rebuild pager, schema fetcher, or checksum implementation. The native client remains the only owner of those semantics on both platforms. Process death, restart, offline writes, response replay, capture pending, schema incompatibility, and rebuild recovery have the same durable behavior through the bridge as through the native API.
Required client behavior summary
Section titled “Required client behavior summary”Every protocol 3 client:
- stores UUID mutation and batch identities before transmission
- preserves complete authored payloads and immutable authored schema references
- seals batches before first transmission and replays them exactly
- reconciles outcomes by exact mutation ID and preserves successor intent
- treats server versions and cursors as opaque
- treats client timestamps as diagnostic only
- pushes before pulling and handles WAL lag as retryable
capture_pending - applies rows, provenance, checksums, and cursors atomically
- persists rebuild IDs, page tokens, session state, and idempotent page receipts
- executes inline schema actions and never requests
fetch - retains schema-incompatible intent and exposes it for explicit resolution
- validates portable seed receipts through authenticated assignment
- uses strict error dispatch and native-owned backoff
- survives process death without silent mutation loss or false progress
- suppresses server-apply capture to prevent echo loops
- exposes local SQL and a typed public status and inspection surface
- keeps React Native as a bridge over native semantics
No client rule in this contract assigns a conflict winner, interprets a server version, invents a scope, or silently discards local intent.