Skip to content

Schema Evolution

This document is the Protocol version 3 contract for registered relations, schema manifests, schema lineage, local SQLite materialization, and schema migration. The PostgreSQL extension is the schema authority. The adapter forwards canonical extension results and does not classify schema changes, construct manifests, or reinterpret schema errors.

The protocol has four independent version surfaces:

Surface Authority Meaning
protocol_version Protocol contract The supported wire contract. This document applies only when the value is 3.
Schema reference Immutable manifest record The client-visible table, field, type, composition, and local-index contract. A reference is the exact pair (version, hash).
scope_set_version Assignment state The authoritative assigned scope set for one client. It does not identify a schema.
membership_generation Scope materialization state The membership rule and materialized-edge generation for one affected scope. It does not identify a schema.

The server owns every schema compatibility decision. A client can only apply a server-issued action. A client never derives compatibility from a version number, a table name, a field name, a physical PostgreSQL type, or local DDL.

The following terms are normative:

  • A relation is a registered PostgreSQL source relation.
  • A table is one client-visible logical synced table represented by one relation in a manifest.
  • A field is one client-visible column of a logical table.
  • A manifest is one immutable stored schema record and its canonical client-visible definition.
  • A schema reference is an exact manifest version and hash pair.
  • A scope is a server-defined selective-sync unit identified by scope_id.
  • A generation is a positive server-issued value that is never reused for a different state.

Every manifest version and generation exposed as a JSON integer is at most 9007199254740991, so Swift, Kotlin, Go, Rust, and JavaScript preserve it exactly. Allocation fails before overflow; no counter wraps or changes sign. Authenticated opaque-token payloads encode their specified unsigned counters as canonical decimal strings instead.

Each physical relation is registered as synced or capture_dependency and receives an immutable relation_id. When a synced relation is first registered, the server also issues immutable client-visible logical IDs:

ID Scope Rule
relation_id Registered source relation Identifies the server-side relation contract.
table_id Client-visible table A Protocol version 3 synced relation represents exactly one logical table.
field_id Field within one table Identifies one client-visible field.

Logical IDs are opaque server-issued strings. They are never derived from human-readable names, never reassigned, and never changed in place. A table or field name is descriptive manifest data, not a row identity key. A field or table rename is a schema transition, not a mutation of an existing logical ID.

A capture_dependency relation has no client table or fields in the schema manifest and is never a push, pull, rebuild, or seed target. Registration stores only its qualified physical identity, complete capture key, captured fields needed by declared impact functions, replica identity, trigger-fence state, and dependent scope rules. Its projected rows are protected internal state.

Each validated registry generation stores this immutable physical relation identity for each relation_id:

physical_schema
physical_relation
physical_relation_oid
replica_identity
capture_key_column
primary_key_field_id for synced registrations
primary_key_type

physical_schema and physical_relation are exact PostgreSQL catalog names. Catalog lookup, publication membership, relation hydration, function resolution, trigger installation, and WAL decoder lookup use the qualified identity. search_path, a bare relation name, and a name-only publication check are invalid lookup mechanisms. physical_relation_oid detects a drop and recreate at the same qualified name.

Two relations with the same unqualified name in different schemas are distinct relations. A registration request that does not identify exactly one qualified physical relation fails with invalid_registration and reason ambiguous_physical_relation.

Registration creates a validated pending registry generation. It becomes active only when the complete generation, its manifest, required capture triggers, scope functions, dependencies, and positive bounds have validated and committed atomically. A relation is never active with partially validated metadata.

For a synced relation, registration defines one resolved synced projection. The projection contains only client-visible fields. Server-only physical columns are absent from the manifest, pull rows, push rows, rebuild rows, and portable seeds. Every synced field has one immutable field_id, one canonical portable type, nullability, and writability. The primary-key and server-owned lifecycle fields are included in the projection and are not writable. A database-generated field is not writable. A BY DEFAULT identity field is writable.

A Protocol version 3 registration has exactly one primary-key column. The column is non-null, is the sole member of the relation’s declared primary-key constraint, is part of the synced projection, and has portable type string, int, or int64. Its column type is one of the built-in key types in Primary key and replica identity contract in ADR 004.

The only accepted PostgreSQL replica identity is DEFAULT backed by that validated primary key. FULL, NOTHING, and USING INDEX are invalid.

The registry rejects all of the following:

  • no primary key
  • a nullable primary-key column
  • more than one primary-key column
  • an expression key
  • a partial key
  • a non-primary unique index offered as a key
  • a primary-key column type that is not a built-in key type
  • a database-generated primary-key column for a registration with push policy enabled
  • a replica identity other than DEFAULT backed by the validated primary key

The primary-key field ID and its declared portable type define every typed row identity. An update patch never writes the primary-key field. The mandatory source trigger also rejects direct SQL UPDATE that changes the key value before version allocation, fence state, logical-message emission, or source-row change. Identity movement requires an explicit delete and insert under normal policy and version rules.

Each synced relation has one registered membership function bound to its relation_id, table_id, exact primary-key SQL type, immutable function identity, declared dependencies, and positive fanout and impact bounds. Its conceptual signature is:

membership_function(p_row_key <registered primary-key SQL type>)
RETURNS SETOF text

The result is the complete set of scope_id values containing the row in the worker’s final commit-ordered projected state for that source transaction. Every returned scope ID is non-null and valid. Duplicate values are removed and the retained set is ordered by unsigned UTF-8 byte comparison before edge materialization.

Every dependency outside the source relation has a registered impact function:

impact_function(p_old_row jsonb, p_new_row jsonb)
RETURNS SETOF synchro_row_ref

p_old_row is null for an insert and p_new_row is null for a delete. Each returned synchro_row_ref contains the declared target table_id, a primary-key type of string, int, or int64, and the typed primary-key value to reevaluate. The impact contract covers old and new dependency state. A revocation, deletion, reassignment, or parent change that can remove membership must identify removed as well as added members.

A membership function that reads sibling rows from its own projection needs a self-impact declaration. The dependency and the target of that declaration are the same relation. Its impact function returns every sibling row whose membership the old or new row image can change. The declared fields cover every own-projection field that the membership function reads. Without a self-impact declaration, a membership function reads only its supplied row from its own projection. Catalog dependencies do not show which rows a read selects, so registration cannot detect an undeclared sibling read.

Membership and impact functions are schema-qualified LANGUAGE SQL, STABLE, and SECURITY INVOKER. Impact functions receive captured old and new dependency images. Membership functions can access only declared, schema-qualified extension projection views for synchronized rows and capture-only dependencies in the worker’s current materialization transaction. They never read live application relations. They cannot depend on volatile values, session identity, session settings, temporary objects, sequence state, random values, wall-clock time, or dynamic SQL. Registration rejects a forbidden dependence that the catalog records, such as a call to an application function. PostgreSQL records no catalog dependency on a built-in function, so registration cannot detect a call to random(), clock_timestamp(), or current_setting(). A function with such a forbidden dependence is unsupported even when registration accepts it.

The validator verifies each exact signature and return type, every declared dependency, required impact function, privilege, bound, and referenced field. All configured fanout, impact, and backfill bounds are positive integers within their configured maximum. Pull and rebuild limits are fixed at 1..1000. A failed function evaluation, an undeclared dependency, a bad result type, a null or invalid scope ID, or an exceeded bound blocks capture. It is never interpreted as an empty membership or impact result.

The server assigns scopes from the canonical authenticated identity. Every user has the default user:{user_id} scope unless an operator revokes it. Registered shared scopes apply to every active client. An operator uses synchro_grant_user_scope(user_id, scope_id) and synchro_revoke_user_scope(user_id, scope_id) for explicit per-user assignments.

Connect reconciles the authoritative assignment set. It advances scope_set_version when the set changes. Added scopes have null cursors and rebuild. Removed scopes require local provenance cleanup. A request cannot add an unassigned scope ID.

Administrative registration errors use this shape and contain no row payload:

{
"error": {
"code": "invalid_registration",
"reason": "unsupported_primary_key_type",
"relation_id": "optional logical relation ID",
"field_id": "optional logical field ID"
}
}

relation_id and field_id are present only when already assigned. reason is exactly one of:

ambiguous_physical_relation
missing_primary_key
nullable_primary_key
composite_primary_key
partial_primary_key
unsupported_primary_key_type
unsupported_replica_identity
invalid_scope_function
undeclared_dependency
invalid_limit

Administrative entry points reject an invalid bound with SQLSTATE 22023. A wire request with an invalid positive limit returns HTTP 400 invalid_request.

The schema manifest is a portable structural model. It is not PostgreSQL DDL, does not expose physical schema or relation names, and does not expose server-only columns. Pull, push, rebuild, local SQLite migration, integrity validation, and seed generation use the same resolved manifest.

The canonical schema definition has this shape:

{
"schema_version": 42,
"schema_hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51",
"parent_schema": {
"version": 41,
"hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
"transition_class": "class_3",
"compatibility_floor": 42,
"tables": [
{
"table_id": "tbl_documents",
"relation_id": "rel_documents",
"name": "documents",
"composition": "multi_scope",
"primary_key_field_id": "fld_documents_id",
"lifecycle": {
"created_at_field_id": null,
"updated_at_field_id": null,
"deleted_at_field_id": null
},
"fields": [
{
"field_id": "fld_documents_amount",
"name": "amount",
"type": "decimal",
"nullable": false,
"writable": true,
"precision": 18,
"scale": 4
},
{
"field_id": "fld_documents_id",
"name": "id",
"type": "string",
"nullable": false,
"writable": false
}
],
"indexes": [
{
"index_id": "idx_documents_amount",
"name": "idx_documents_amount",
"field_ids": ["fld_documents_amount"],
"unique": false
}
]
}
]
}

Every definition contains these required semantics:

  • schema_version is a positive, never-reused manifest version.
  • schema_hash is exactly 64 lowercase hexadecimal characters representing the manifest SHA-256 digest.
  • parent_schema is null for the initial manifest and otherwise contains the exact immutable parent version and hash.
  • transition_class is exactly initial, class_2, class_3, or class_4. Class 1 does not publish a manifest.
  • compatibility_floor is the positive version number of the earliest manifest in the current contiguous Class 2 lineage.
  • tables contains every registered logical table exactly once.
  • A table contains immutable table_id and relation_id, one current local name, explicit composition, exactly one primary_key_field_id, and every synced field exactly once.
  • A field contains immutable field_id, its current local name, one canonical type, nullable, and writable.
  • A decimal field contains positive precision and an integer scale in the inclusive range 0..precision. Non-decimal fields omit precision and scale.
  • A lifecycle member is either the field ID of the declared lifecycle field or null when that lifecycle field is not declared. A declared lifecycle field has type datetime and writable: false.
  • indexes is the complete client-owned index set for the table. Each index has an immutable index_id, a local name, an ordered nonempty field_ids list, and unique. Index field order is semantic and is not sorted.

The collections tables, fields, and indexes are semantically unordered and are canonicalized in unsigned UTF-8 byte order by table_id, field_id, and index_id. A field’s name, a table’s name, and an index’s name do not replace its immutable ID in canonical ordering, row identity, mutation identity, or migration bookkeeping.

Immutable Manifest Body, Hash, And Lineage

Section titled “Immutable Manifest Body, Hash, And Lineage”

A published manifest is one immutable record containing:

schema_version
schema_hash
canonical_manifest_body
parent_schema
transition_class
compatibility_floor

canonical_manifest_body contains schema_version, parent_schema, transition_class, compatibility_floor, and the complete client-visible definition shown above, excluding schema_hash. The initial manifest has parent_schema: null and transition_class: "initial". Every later manifest records the exact schema reference of its immediate immutable parent. Versions advance monotonically in this stored lineage and are never reused. A client or server never infers the parent from numeric version order alone.

transition_class is the deterministic Class 2, Class 3, or Class 4 transition from the parent manifest. Class 1 does not mint a manifest. The initial record uses initial. compatibility_floor is the earliest manifest version reachable through the current contiguous sequence of Class 2 transitions. It equals the manifest’s own version after an initial, Class 3, or Class 4 boundary. A Class 2 floor never names a version newer than its parent manifest. The immutable manifest history resolves that version to its exact hash. The server evaluates the complete known lineage between a client reference and the current reference. It never decides compatibility from compatibility_floor or version ordering alone.

The record body, hash, version, parent reference, transition class, compatibility floor, table IDs, field IDs, composition, and index definitions are inserted atomically. No member of a published record is updated in place. Historical references always resolve to their original immutable record. A live catalog query is never substituted for a historical manifest.

The schema hash is:

SHA-256(
ASCII("synchro:v3:schema-manifest:v1") || 0x00 ||
RFC8785_UTF8(canonical_manifest_body)
)

The manifest body is canonicalized with RFC 8785 after every semantically unordered collection is sorted by immutable logical ID. The hash input excludes schema_hash and operational values such as generation time. The server reads the body, hash, and version from the same immutable record. A live body paired with a stored hash or version is invalid.

The only Protocol version 3 manifest types are:

string
int
int64
decimal
float
boolean
datetime
date
time
json
bytes

A manifest type is exactly one value from this list. The server never emits a database type name, parameterized type spelling, array annotation, alternate case, or type alias in a manifest. A client rejects a manifest with any other type value. Registration resolves each synced physical field to one of these types before activation. A source value that cannot be represented by its declared portable type is invalid for the registered projection.

decimal and float are distinct. Exact base-10 values use decimal; binary floating-point values use float. PostgreSQL numeric and decimal values map to decimal, never float.

Type Canonical wire value
string JSON string containing valid Unicode scalar values. No Unicode normalization occurs.
int JSON integer in [-2147483648, 2147483647], with no fraction or exponent. Zero is 0, never -0.
int64 JSON string containing a base-10 signed 64-bit integer with no leading +, no leading zero except 0, and no negative zero.
decimal JSON string containing canonical exact decimal text.
float Finite JSON number representing IEEE 754 binary64 with RFC 8785 number serialization. NaN and infinities are invalid; negative zero is canonical positive zero.
boolean JSON true or false.
datetime JSON UTC string with exactly microsecond precision: YYYY-MM-DDTHH:MM:SS.ffffffZ. Leap seconds and non-UTC offsets are invalid.
date JSON string YYYY-MM-DD containing a valid Gregorian date.
time JSON string with exactly microsecond precision: HH:MM:SS.ffffff. Leap seconds and time-zone suffixes are invalid.
json JSON string whose decoded content is one complete I-JSON value in RFC 8785 canonical UTF-8 form.
bytes JSON string containing unpadded RFC 4648 base64url. Noncanonical encodings are invalid.

Canonical decimal text has no exponent, leading +, leading zero in a nonzero integer part, trailing fractional zero, trailing decimal point, or negative zero. Precision and scale are validated against the field declaration before canonicalization. Decimal arithmetic and conversion use an exact decimal implementation. A decimal value never passes through binary floating point.

The decoded content of a json value has unique object member names and uses RFC 8785 ordering, escaping, and nested-number serialization. A value outside the I-JSON numeric domain is represented as a JSON string. An implementation-native object serializer is not a canonicalization algorithm.

SQLite stores the manifest’s logical type, not an inferred SQLite affinity. The client validates and binds values according to this table on every local write, server apply, migration copy, seed import, and readback used for protocol encoding or integrity verification.

Portable type SQLite declaration Binding and preservation rule
string TEXT Bind and read UTF-8 text.
int INTEGER Bind and read a signed 32-bit integer.
int64 INTEGER Bind and read a native signed 64-bit integer. The wire value remains text, and no bridge converts it through a binary64 or JavaScript number.
decimal TEXT Bind and read canonical decimal text. Do not use REAL, NUMERIC, or floating-point conversion.
float REAL Bind and read finite IEEE 754 binary64 values.
boolean INTEGER Bind only 0 or 1, and decode only those values as false or true.
datetime TEXT Bind and read the canonical UTC microsecond string.
date TEXT Bind and read the canonical date string.
time TEXT Bind and read the canonical microsecond time string.
json TEXT Bind and read the canonical JSON string without reparsing through an implementation-native serializer.
bytes BLOB Bind and read raw bytes; encode base64url only at the wire boundary.

SQLite’s signed INTEGER storage preserves every int64 value when native 64-bit bindings are used. decimal uses TEXT because SQLite numeric affinity can coerce exact decimal text to a lossy binary value. A client bridge carries int64 as its canonical string or a native signed 64-bit value and carries decimal as canonical text. It never carries either value as an untyped JSON number.

The manifest’s logical nullability controls value validation. A required field without a constant default can use nullable SQLite storage when a migration adds the field. This storage can remain nullable after rebuild. Clients must not invent defaults or accept canonical nulls for logically nonnullable fields. A logically nullable field must not have physical NOT NULL enforcement. Physical nullability does not verify rows or complete a migration.

Every table declares exactly one composition value:

Value Local materialization contract
single_scope At most one active scope provenance edge justifies a local row identity.
multi_scope More than one active scope provenance edge can justify the same local row identity.

multi_scope requires durable local provenance keyed by (scope_id, table_id, typed_primary_key). Removing one provenance edge removes the local row only when no other valid edge remains. single_scope does not permit a client to infer or synthesize a second scope justification. The server is authoritative for every provenance edge.

Composition is manifest data, not a client optimization hint. A client rejects a manifest with an absent or unknown composition value. A table’s composition is part of local schema identity, migration planning, row cleanup, rebuild behavior, and schema hashing.

The server assigns exactly one class to every change. Classification is derived from the parent and child manifest plus the membership materialization contract; an operator, client, or adapter cannot select a different class for the same transition.

Classification uses this ordered decision:

  1. A change that leaves the current manifest unchanged is Class 1.
  2. A change that removes or renames a table or field, changes a field type or primary-key identity, tightens nullability, or removes writability is Class 4.
  3. A remaining compatible change that requires authoritative historical rematerialization is Class 3.
  4. A remaining additive or permissive compatible change that requires no historical rematerialization is Class 2.
  5. Every remaining client-visible change is Class 4.

A Class 1 change does not alter the client-visible schema definition. Examples include a server-side policy change and a membership rule or dependency change that leaves table shape and composition unchanged. Class 1 changes registry or membership state and do not mint a new schema manifest. For an exact schema reference, connect returns none.

A Class 1 membership change still advances the affected membership_generation, invalidates only the affected scope cursors, and requires those scopes to rebuild. It does not use a schema version bump to signal data movement.

A Class 2 change is a client-visible additive change that requires no historical rematerialization. It mints a new immutable manifest. The complete path from the client manifest to the current manifest must contain only compatible Class 2 transitions for connect to return replace.

Class 2 changes include these changes when they do not require historical materialization:

  • add a nullable field whose existing source values are all SQL NULL
  • add an empty table
  • add a declared local index
  • change nullable from false to true or writable from false to true while every earlier field and table contract remains valid
  • widen a decimal field so that its scale and its integer digits (precision minus scale) do not decrease

The server establishes historical nullness from the actual source rows when the registry generation becomes validated, under the registry and source write gates. Nullable metadata and a column default do not establish it. The server records the result once with that generation, bound to its stored parent generation and target definition. Bootstrap admission and manifest publication read that recorded result. A later source write cannot change the class of an admitted generation. A registry generation without a recorded result, such as a pending generation from an earlier release, requires verified bootstrap and is classified as requiring historical values.

Class 3: Compatible Rebuild-Requiring Change

Section titled “Class 3: Compatible Rebuild-Requiring Change”

A Class 3 change preserves a representable client contract but requires authoritative local rematerialization for affected scopes. It mints a new immutable manifest. For one connect response, the server intersects the transition’s affected scopes with that client’s authoritative assigned set. A nonempty intersection returns rebuild_local with exactly that set; an empty intersection returns replace and forbids affected_scopes.

Class 3 changes include:

  • add a table that already has authoritative rows
  • add a field whose historical values must be materialized, including a re-exposed field with a non-null value and an added field with a populated default
  • change single_scope to multi_scope or multi_scope to single_scope
  • change membership materialization in a way that changes local provenance or requires a new local materialization contract
  • perform controlled physical relation reconfiguration, even when the logical table and fields remain equivalent

A Class 3 manifest requiring a table, field, dependency, or projection value that the active materialized projections do not contain remains pending. Before publication, the server creates a permanent candidate logical slot through the replication protocol with an exported snapshot, stages the complete pending projections and membership state from that snapshot, consumes the slot in commit order through a transaction-end barrier also reached by the main worker, verifies the staged state, and atomically activates the manifest, registry, membership generations, projections, and affected-scope invalidation. Connect cannot return that manifest before activation, and rebuild never substitutes an independent live source query for this baseline.

Bootstrap state durably binds the permanent candidate slot, database, plugin, consistent point, imported snapshot, target manifest, catch-up barrier, and phase. Process death before activation either resumes from that exact slot and boundary or discards the complete stage and begins with a new slot and snapshot. It never exposes the target manifest, partially activates projections, or changes prior connect dispatch before one atomic verified activation.

Class 4: Incompatible Structural Or Identity Change

Section titled “Class 4: Incompatible Structural Or Identity Change”

A Class 4 change is incompatible with at least one earlier client schema. It mints a new immutable manifest and starts an incompatible lineage. Connect returns unsupported for a known history that crosses this boundary.

Class 4 changes include:

  • remove or rename a table or field
  • change a field’s portable type
  • narrow a decimal field’s scale or integer digits
  • tighten a field from nullable to non-null
  • remove writability from a field that earlier manifests accepted as writable
  • change a primary-key field, primary-key type, or primary-key identity

Any client-visible transition not listed as Class 1, Class 2, or Class 3 is Class 4. An in-place rename and an incompatible in-place type conversion are Class 4 transitions.

POST /sync/connect is the only operation that accepts a historical schema reference to select a schema action. Protocol compatibility is evaluated before schema lineage.

The fresh schema sentinel is exactly:

{
"version": 0,
"hash": ""
}

Every non-fresh reference has a positive JSON integer version and a 64 lowercase-hexadecimal-character hash. A reference with an invalid version or hash, a zero version other than the fresh sentinel, or an empty hash other than the fresh sentinel is malformed and returns HTTP 400 invalid_schema_reference.

The fresh sentinel is valid only when client_generation is absent and the authenticated client identity has no prior generation or installed schema. A continuing, restored, or expired client presents its actual installed schema. Pairing any generation with the sentinel, or omitting the generation for a previously known client identity, returns 400 invalid_schema_reference before lineage dispatch. This cannot be used to bypass Class 4 unsupported or the explicit reset contract.

For Protocol version 3, dispatch is exactly:

protocol incompatible -> HTTP 426 upgrade_required
fresh sentinel with generation or prior client state -> HTTP 400 invalid_schema_reference
fresh client identity and fresh schema sentinel -> HTTP 200 replace with inline current manifest
exact current schema -> HTTP 200 none
known lineage containing only compatible Class 2 transitions -> HTTP 200 replace with inline current manifest
known lineage containing a Class 3 transition and no Class 4 transition, with affected assigned scopes -> HTTP 200 rebuild_local with inline current manifest and affected scopes
known Class 3 lineage with no affected assigned scope -> HTTP 200 replace with inline current manifest
unknown lineage or a lineage containing a Class 4 transition without explicit reset -> HTTP 200 unsupported
valid explicit reset with assigned scopes -> HTTP 200 rebuild_local with inline current manifest and all assigned scopes
valid explicit reset with no assigned scope -> HTTP 200 replace with inline current manifest

fetch is not a Protocol version 3 action. Connect never emits fetch, and no follow-up schema endpoint participates in this state transition.

The response schema object always contains exactly the current manifest reference and one action:

{
"schema": {
"version": 42,
"hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51",
"action": "rebuild_local"
},
"schema_definition": {
"schema_version": 42,
"schema_hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51",
"parent_schema": {
"version": 41,
"hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
"transition_class": "class_3",
"compatibility_floor": 42,
"tables": [
{
"table_id": "tbl_documents",
"relation_id": "rel_documents",
"name": "documents",
"composition": "multi_scope",
"primary_key_field_id": "fld_documents_id",
"lifecycle": {
"created_at_field_id": null,
"updated_at_field_id": null,
"deleted_at_field_id": null
},
"fields": [
{
"field_id": "fld_documents_amount",
"name": "amount",
"type": "decimal",
"nullable": false,
"writable": true,
"precision": 18,
"scale": 4
},
{
"field_id": "fld_documents_id",
"name": "id",
"type": "string",
"nullable": false,
"writable": false
}
],
"indexes": [
{
"index_id": "idx_documents_amount",
"name": "idx_documents_amount",
"field_ids": ["fld_documents_amount"],
"unique": false
}
]
}
]
},
"affected_scopes": ["scope_a", "scope_b"]
}

Action-specific members are exact:

Action Required members Forbidden members
none schema.version, schema.hash, schema.action schema_definition, affected_scopes
replace schema_definition containing the complete current immutable manifest affected_scopes
rebuild_local schema_definition containing the complete current immutable manifest; nonempty affected_scopes None of the required members can be omitted.
unsupported schema.version, schema.hash, schema.action, and schema.reason equal to unknown_schema_lineage or incompatible_schema_transition schema_definition, affected_scopes

affected_scopes is the complete deduplicated list of assigned scopes affected by the transition, ordered by unsigned UTF-8 byte comparison. A client applies the manifest before rebuilding only those scopes. unsupported is a successful HTTP 200 response that moves the client to an upgrade-required local state. It does not permit normal push, pull, rebuild, schema guessing, or a schema fetch.

The standard connect envelope also contains a scope_cursor_updates delta for existing assignments. For every presented historical-schema cursor, the server issues a current-schema cursor at the exact same position after proving the entire lineage compatible and the scope unaffected by every Class 3 transition. It returns null only when the scope is affected or another binding or the resume floor is stale. Added and removed scopes stay in the assignment delta. The client installs a replacement only with target-manifest activation, and pull never accepts the historical token. This explicit bridge is what permits Class 2 and unaffected Class 3 scopes to continue while exact schema-hash cursor binding remains mandatory.

After application upgrade or explicit operator-approved recovery, a continuing client can request a synchronized-schema reset by sending its current positive client_generation, the schema reference actually installed locally, and schema_reset: true. The reset is accepted only when protocol 3 and the current minimum client runtime are satisfied. A fresh sentinel cannot be combined with reset.

The server returns the complete current inline manifest. If any scope is assigned, the action is rebuild_local, affected_scopes is exactly the full assigned set, and all affected cursors are null. If no scope is assigned, the action is replace. The client replaces synced-table materialization and protocol state while preserving local-only data, complete authored mutation records, sealed batch history, and inspectable outcomes. Queued fields remain subject to authored-schema compatibility and are never silently removed.

Push, pull, and rebuild require the exact current immutable schema reference:

request.schema.version == current.schema_version
request.schema.hash == current.schema_hash

Both comparisons are required. A matching version with another hash, a matching hash with another version, a historical manifest, and an unknown manifest are all mismatches. The server performs no partial push, pull, rebuild, cursor acknowledgement, checkpoint update, or scope update before returning HTTP 422:

{
"error": {
"code": "schema_mismatch",
"message": "the request schema is not current",
"retryable": false,
"current_schema": {
"version": 42,
"hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51"
},
"received_schema": {
"version": 31,
"hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
}
}
}

The client reconnects, executes the returned schema action, and sends the exact current reference in a later operation. A historical schema reference is valid only in connect lineage dispatch.

Each queued mutation remains a durable record of the original local intent. It contains at least:

batch_id
mutation_id
table_id
typed_primary_key
operation
base_version
client_version
columns: ordered field_id and value entries
authored_schema: version and hash
local_order

The queue retains presence and absence distinctions, every authored field ID and value, and the original local order. Before sealing, reconciliation of an accepted predecessor for the same row may refresh only the unsealed successor’s server-owned base_version. The mutation keeps its ID and authored intent. After sealing, the base version and all request content remain immutable. The client contract defines this reconciliation rule. The request-level schema is the exact current reference. authored_schema identifies the immutable manifest under which the client captured the local intent.

The client does not rename a queued field, drop an unknown field, overwrite authored_schema, or alter a sealed mutation to fit a newer manifest. The server evaluates preserved table and field IDs against the current manifest after the request-level exact-current schema gate. If each referenced field is still writable and compatible, the server evaluates the mutation under the current request schema. If a retained table or field cannot be represented, the server returns this terminal per-mutation outcome in an HTTP 200 push response:

{
"mutation_id": "0197e83a-f7e8-7e21-92c0-c621732bd5de",
"outcome_schema": {
"version": 42,
"hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51"
},
"status": "rejected_terminal",
"code": "schema_incompatible",
"message": "authored mutation cannot be represented by the current schema",
"retryable": false,
"authored_schema": {
"version": 31,
"hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
"current_schema": {
"version": 42,
"hash": "5dc97fc5ea571dd7555d877e08cecc102113c6efd63976d37d498341c8b32d51"
},
"incompatible_field_ids": ["fld_documents_legacy_name"]
}

incompatible_field_ids contains every incompatible authored field ID in unsigned UTF-8 byte order. For a delete that is incompatible only because its table no longer exists, the array is empty. Field values never appear in this outcome.

schema_incompatible has retryable: false. The client stores the complete original mutation and outcome durably in a blocked, inspectable state and excludes it from automatic retry. A replacement or mapped user intent has a new mutation ID and exists only after explicit application or user reconciliation.

For a request-level schema_mismatch, the sealed batch and every mutation stay unresolved. A successor batch has a new batch ID and the exact current request schema. An unchanged mutation keeps its mutation ID, complete payload, and authored-schema reference. Any mutation whose authored identity or content changes has a new mutation ID. The original intent remains inspectable.

Synchro owns SQLite DDL for synced tables and Synchro metadata. Applications own only local-only tables, indexes, views, caches, and data outside the synced-table contract. An application does not independently migrate a synced table.

Before local schema DDL begins, the client writes a durable migration journal containing journal_version, source and target schema references, the complete verified target schema_definition and canonical manifest body, action, exact affected scopes, migration_plan_version, a complete ordered typed DDL and synchronized-metadata operation plan, and migration phase. The structured plan is deterministically derived from the persisted source and target manifests, contains no unvalidated raw SQL, and is hash-bound to both references. The journal and schema metadata are part of the local sync database.

The client performs these transitions in order:

  1. Pause normal push and pull.
  2. Verify and persist the complete migration journal and operation plan before any synced-table DDL.
  3. Apply the manifest-defined SQLite DDL and synchronized metadata update in one SQLite transaction.
  4. Persist the target schema reference only with the successful DDL commit.
  5. In that commit, install server-issued current-schema cursor replacements for unaffected existing scopes and mark every null cursor update rebuild-required. Never copy or relabel a historical token locally.
  6. For rebuild_local, durably mark every affected_scopes entry as requiring rebuild before any normal incremental cursor can advance for that scope.
  7. Resume only push scheduling under the target schema. Preserve authored schema identities, create successor batches where required, and drive every push-eligible queued intent to an applied, conflict, terminal rejection, or inspectably blocked state before rebuild. Pull remains paused.
  8. Rebuild each affected scope and verify its final checksum before installing its final scope cursor. The rebuild snapshot therefore includes every accepted write whose fence is satisfied by exact WAL materialization or activated verified reset-baseline coverage and cannot overwrite unresolved local intent. Rebuild pruning preserves the application-visible local row for such intent outside the rebuilt server-provenance layer. When the target shape cannot hold that local row, the rebuild installs the server row, and the intent stays unchanged and inspectable.
  9. Clear the migration journal only after the required local DDL, metadata, and any required rebuild state, provenance, checksums, and final cursors are durable.
  10. Resume normal pull and ordinary scheduling.

SQLite DDL, target schema metadata, queue preservation, affected-scope markers, and the migration phase commit atomically. A failed transaction leaves the previous physical schema and previous active schema reference intact. A process restart reads the durable journal before issuing a sync request. When the source schema is active, it revalidates the persisted target body and executes the persisted plan transactionally. When the target schema and rebuild markers are already durable, it does not repeat DDL and resumes push-first reconciliation and rebuild. Any other physical schema, active reference, body, plan, or phase combination preserves queues and local-only data, issues no sync request, and enters an explicit non-retryable local error. Recovery never fetches schema; protocol version 3 has no such operation.

A connect response can require a new migration while a committed journal still waits for rebuild. The client replaces the earlier journal in the transaction that persists the new journal, before any new DDL. The new journal uses the active schema as its source. Its affected scopes include each scope of the earlier journal whose rebuild is not final, so the rebuild obligation stays journaled. A journal before its DDL commit, or a journal whose target is not the active schema, still blocks every different migration.

The durable journal makes process death safe at every boundary. A crash before the SQLite transaction leaves a pending journal and no target schema. A crash after the transaction leaves a target schema with durable rebuild requirements. A crash during rebuild uses the durable rebuild page state and does not install a final cursor or checksum before verification. Queue entries remain complete through every migration phase.

Changing single_scope and multi_scope changes the local materialization model and is always Class 3. The client applies the new manifest, creates or removes the required provenance structures transactionally, marks the listed scopes for rebuild, and preserves a row while any valid multi_scope provenance edge remains. It does not perform a global local wipe or create a local delete mutation during provenance cleanup.

Scope assignment changes, row movement between scopes, and membership-rule changes with unchanged table shape and composition do not mint a schema manifest. They use scope_set_version, membership generation invalidation, normal scoped changes, or scope rebuild as applicable. A schema version is not used as a substitute for scope movement.

Every destructive or semantic schema transition uses this exact rollout:

  1. Expand: Publish a new compatible field or table with new immutable IDs. Keep the prior field or table contract available.
  2. Migrate: Dual-read, dual-write, and backfill under server-controlled logic until the new representation is authoritative and active clients have crossed the compatible lineage.
  3. Contract: Publish the Class 4 removal, rename retirement, type retirement, or identity retirement only after the old contract is no longer served to active compatible clients. Clients on the old side receive unsupported or terminal schema_incompatible outcomes for preserved queued intent.

An in-place field rename is not a rollout mechanism. A rename expands with a new field ID, migrates data and readers, then contracts by retiring the prior field. An incompatible type change follows the same sequence. A primary-key change creates a new logical table identity and is Class 4.

Membership Generations And Controlled Reconfiguration

Section titled “Membership Generations And Controlled Reconfiguration”

registry_generation is a positive monotonic generation for the complete registered-relation configuration. It changes when an activated relation, physical identity, function identity, dependency declaration, replica identity, or configured bound changes. A worker selects one validated registry generation for each source transaction and does not combine metadata from generations.

membership_generation is a positive monotonic generation for the materialization contract of one affected scope. Incremental cursors and rebuild sessions bind the exact membership generation. Ordinary row changes under an unchanged membership contract do not advance it.

Rule changes, dependency changes, composition changes, and controlled relation reconfiguration use this sequence:

  1. Persist a complete validated pending registry generation and intended membership generation.
  2. When active projections contain every value needed by the pending contract, open one repeatable-read snapshot of them, read its visible durable transaction-end boundary, and stage the new edge set in bounded positive-size batches. When any required table, field, dependency, or projection is absent, use the logical-slot exported-snapshot bootstrap defined above to stage the complete pending projections and edges.
  3. Apply every source and dependency transaction after that boundary to the staged projections and edge set through the same functions until a later transaction-end activation boundary also reached by the main worker is fully caught up.
  4. Verify the staged edge set and atomically activate the registry and membership generations.
  5. Mark only affected scopes for rebuild and retain the prior generation for active cursor, rebuild-session, and retention-lineage obligations.

The live edge set is never deleted and rebuilt in place. A client never receives a partially backfilled edge set. Unaffected scopes keep their resumable state.

Direct relation rename, schema move, drop, recreation, primary-key change, or replica-identity change is unsupported while a relation is registered. Detecting any such drift records durable registered_relation_drift state and transitions the relation to capture_blocked. Capture does not acknowledge or skip affected source changes, and readiness is unready until recovery completes.

The public response for an operation blocked by this state is HTTP 503:

{
"error": {
"code": "temporary_unavailable",
"retryable": true,
"reason": "capture_blocked"
}
}

The response contains no physical relation name, row data, or worker detail. There is no automatic rebinding by unqualified name, qualified name, or relation OID.

Recovery from relation drift requires a controlled reconfiguration. The operator submits a new complete registration, validates the new qualified physical identity, stages membership under a new generation, creates the required immutable manifest, invalidates affected scope generations, and requires affected scope rebuild before capture resumes. The reconfiguration retains an existing relation, table, or field logical ID only when its logical contract and one-column primary-key contract remain valid. A key change creates a new logical table ID and a Class 4 transition. An explicitly reconfigured but otherwise equivalent relation still requires affected scopes to rebuild.

Portable seeds are verified caches for explicitly declared portable shared scopes. A seed includes the immutable schema definition and exact schema reference, complete SQLite schema metadata, scope provenance, scope digests, cardinalities, and portable seed continuation receipts. It contains no ordinary client-bound cursor and does not grant authorization.

The seed generator creates the SQLite artifact atomically with server-apply capture suppressed, an empty mutation queue, complete provenance, every verified opaque row version installed as local compare-and-swap base, verified schema metadata, and no partially installed scope cursor. It verifies the manifest hash, every row digest, every scope digest, SQLite integrity, and read-only reopen before publication.

On the first authenticated connect, the server validates each seed receipt against the current schema, assigned scope, registry generation, membership generation, retention generation, stream boundary, cardinality, and scope digest. For an assigned portable scope with a valid exact schema, the server issues a normal user-bound and client-bound incremental cursor at the seed boundary. An unassigned scope loses seed provenance and unchanged cache rows, but pending authored mutation and local row state survive for normal server authorization. A stale, unverifiable, or schema-mismatched assigned scope is rebuilt after pending intent follows push-first scheduling. A seed whose manifest differs from the current manifest follows normal connect dispatch: replace, rebuild_local, or unsupported.

A seed never bypasses schema lineage, assignment validation, checksum verification, migration journaling, or rebuild. It is not a second sync mode.

All schema-related dispatch is fixed by this table. Error messages are bounded diagnostics; clients branch on HTTP status, code or action, required fields, and retryability.

Condition HTTP status Code or action Required fields Retryable
Protocol version is not 3 426 upgrade_required required_protocol_version, received_protocol_version false
Connect schema reference is malformed 400 invalid_schema_reference received_schema false
Connect schema reference is the fresh sentinel 200 schema.action: "replace" schema_definition Not applicable
Connect schema reference is exact current 200 schema.action: "none" schema.version, schema.hash Not applicable
Known compatible Class 2 lineage 200 schema.action: "replace" schema_definition Not applicable
Known lineage with Class 3, no Class 4, and affected assigned scopes 200 schema.action: "rebuild_local" schema_definition, nonempty affected_scopes Not applicable
Known Class 3 lineage with no affected assigned scope 200 schema.action: "replace" schema_definition Not applicable
Unknown lineage or a lineage with Class 4 without explicit reset 200 schema.action: "unsupported" schema.version, schema.hash, schema.reason Not applicable
Valid explicit reset with assigned scopes 200 schema.action: "rebuild_local" schema_definition, all assigned affected_scopes Not applicable
Valid explicit reset with no assigned scope 200 schema.action: "replace" schema_definition Not applicable
Push, pull, or rebuild schema reference is not exact current 422 schema_mismatch current_schema, received_schema false
First-execution push, pull, or rebuild has missing, stale, or expired client generation 409 client_generation_expired current_client_generation false
Rebuild continuation is invalidated by schema, membership, retention, stream, or client generation change 409 rebuild_restart_required scope_id false
Registered relation drift or membership evaluation blocks capture 503 temporary_unavailable reason: "capture_blocked" true
Retained queued intent contains an incompatible table or field 200 Per-mutation schema_incompatible authored_schema, current_schema, incompatible_field_ids false
Pull or rebuild limit is absent, non-integer, or outside 1..1000 400 invalid_request No field-level limit members are required. false

none, replace, rebuild_local, and unsupported are schema actions, not error codes. schema_incompatible is a terminal per-mutation outcome, not a whole-request error. fetch is absent from Protocol version 3.