Skip to content

ADR 004: Membership, Schema, and Retention

ADR 004: Membership, Schema, and Retention

Section titled “ADR 004: Membership, Schema, and Retention”

Accepted, 2026-07-18.

Selective sync is only correct when a row’s identity, scope membership, schema lineage, and resumable history remain unambiguous while application schemas and membership rules change. A bare relation name, an inferred dependency, a mutable manifest, or a retention floor derived from the current changelog can silently give the same client state different meanings over time.

This decision freezes the v0.3 contract for registered relations, scope membership, schema evolution, queued mutations, client generations, and retention. It is intentionally fail-closed. Existing implementation behavior does not define this contract.

Synchro v0.3 uses immutable logical identities, schema-qualified physical identities, registered deterministic scope functions with declared dependency impacts, staged membership generations, immutable schema manifests, and durable per-scope retention state. Protocol version 3 uses inline schema delivery only. It never emits the fetch schema action.

The following terms are normative:

  • A relation is a PostgreSQL source relation registered for capture.
  • A table is the client-visible logical synced table represented by a relation in a schema manifest.
  • A field is one client-visible column of a logical table.
  • A scope is a server-defined selective-sync unit identified by scope_id.
  • A generation is a positive, server-issued value that distinguishes incompatible states. It is never reused for a different state.

Every registration has kind synced or capture_dependency and records an immutable relation_id. A synced registration also records the following client-visible logical IDs:

Field Meaning
relation_id Server-side logical identity of the registered source relation.
table_id Client-visible logical table identity. A v0.3 synced relation has exactly one table ID.
field_id Client-visible logical field identity, unique within table_id.

Logical IDs are generated by the server when a relation or field is first introduced. They are never derived from names, never reused, and never changed in place. Human-readable table and field names are manifest attributes, not identity keys.

A capture_dependency registration has no table_id, no client-visible fields, no manifest table, and no direct pull, rebuild, push, or seed surface. It declares only the physical relation, complete capture key, captured fields needed by impact evaluation, replica identity, trigger fences, and dependent scope rules. Its fence messages identify registration_kind, relation_id, qualified relation, capture-key identity, operation, transition version, fence ID, and registered-DML ordinal, and MUST omit table_id. Its captured values are protected internal projection state.

The registry also records this schema-qualified physical identity:

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 catalog names. All catalog lookup, publication management, hydration, and scope function resolution use the schema-qualified identity. search_path is not part of the registration contract. physical_relation_oid detects a drop and recreation that happens to reuse the same qualified name.

This distinction permits a controlled reconfiguration to retain a logical ID only when the logical table and field contract remains valid. It does not permit a direct physical change to be treated as a harmless rename.

Registration validates the physical relation, every referenced field, the key, the replica identity, the registered functions, their dependencies, and every configured bound before activation. The validation result is atomic with the new registry generation. A relation is never active with partially validated metadata.

Two physical relations with the same unqualified name in different schemas are different registration candidates. A registration request that cannot identify exactly one schema-qualified relation fails with invalid_registration and reason: "ambiguous_physical_relation".

v0.3 supports exactly one primary-key column. The column must be non-null and must be the sole column of the relation’s declared primary-key constraint. The constraint must not be deferrable. PostgreSQL uses only an immediate key as the replica identity. A synced registration with push policy enabled must not use a database-generated key. The portable type of the key column must be exactly one of:

string
int
int64

string includes PostgreSQL types mapped to the portable string type, such as UUID and text. int and int64 retain their distinct portable types. The registered primary-key field is part of the synced shape and is not writable as an update patch field. Both synced mutation validation and the mandatory source trigger reject an update that changes the primary-key value before any fence or source-row effect. A logical identity change is an explicit delete and insert under their normal authorization and version rules, not an update.

Fences, the WAL worker, row versions, and clients identify a row by the text form of its key. The fence makes that text in the writer session. The WAL worker makes it in its own session. Thus the key column type must be one of these built-in PostgreSQL types:

int smallint, integer
int64 bigint
string text, character varying, character, uuid, inet, cidr, macaddr,
macaddr8, int4range, int8range, numrange, int4multirange,
int8multirange, nummultirange

The output function of each type reads no configuration parameter, and equal text means an equal key. The text form of interval and of the date and time ranges depends on DateStyle, IntervalStyle, or TimeZone. A user-defined type can have any output function. The registry identifies the key type by its OID, so it also rejects a domain over a type in the list.

The following registrations are rejected:

  • no primary key
  • a nullable key column
  • a deferrable primary key
  • more than one primary-key column
  • an expression key
  • a partial unique index offered as a key
  • a non-primary unique index offered as a key
  • a key whose column type is not in the list above

The only accepted PostgreSQL replica identity is DEFAULT backed by the validated primary key. FULL, NOTHING, and USING INDEX are not supported in v0.3. The replica identity recorded at registration must continue to match the catalog value while capture is active.

Registration failures use this administrative error shape:

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

reason is 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, or invalid_limit. The error carries no row payload.

Relation drift and controlled reconfiguration

Section titled “Relation drift and controlled reconfiguration”

Direct rename, schema move, drop, key change, or replica-identity change of a registered relation is unsupported. Detection of any such drift transitions the affected relation to capture_blocked with the durable reason registered_relation_drift. Capture does not acknowledge or skip affected source changes while that state exists.

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

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

The adapter maps this response to HTTP 503. It does not expose catalog names, row contents, or worker diagnostics in the response.

Recovery is a controlled reconfiguration, not an automatic rebinding. The operator must submit a new validated registry configuration, stage membership for it, create the required immutable manifest, invalidate affected scope generations, and require affected scopes to rebuild before capture resumes. A key change creates a new logical table identity and is a Class 4 schema change. An explicit reconfiguration of an otherwise equivalent relation still requires the affected scopes to rebuild.

Scope membership for a synced relation is produced only by registered functions. A function is bound to a relation_id, a table_id, an immutable function identity, a declared signature, declared dependencies, and positive fanout and impact bounds.

For each registered relation, the membership function has this conceptual SQL signature after substituting the registered primary-key SQL type:

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

It returns the complete set of scope_id values that contain the source row in the worker’s commit-ordered projected state. A result is normalized as a set: every item must be non-null and valid, duplicates are removed, and the retained values are ordered by unsigned UTF-8 byte comparison before membership is materialized.

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. synchro_row_ref has these fields:

Field Meaning
table_id The affected logical table ID. It must equal the target declared by the dependency.
pk_type One of string, int, or int64.
pk_value The typed primary-key value of a row that must have membership reevaluated.

A membership function can read sibling rows from its own projection. That function needs a self-impact declaration, whose dependency and target are the same relation. The impact function returns the sibling rows that the old or new row image can affect. The worker reevaluates those rows from the final projections of the source transaction. Without that declaration, a membership function reads only its supplied row from its own projection.

Scope assignment uses four sources:

  • user:{canonical_user_id} is assigned by default.
  • A registered shared scope is assigned to every active client.
  • synchro_grant_user_scope(user_id, scope_id) assigns one scope to one canonical user. synchro_revoke_user_scope(user_id, scope_id) removes that explicit assignment. A user can also revoke its default private scope.
  • The registered assignment function returns scopes for one canonical user from application data.

Connect and pull compute the authoritative set with the same rule. The set is the union of the four sources. Duplicates are removed, and the set uses the existing scope ID order. A changed set advances scope_set_version by exactly one. An unchanged set writes no assignment state. An added scope gets a generation_start checkpoint and a null cursor and requires rebuild. A removed scope keeps its client checkpoint row and requires provenance cleanup. A client cannot invent a scope ID or grant itself a scope.

Pull reconciles the set after every request validation passes and before it builds scope_updates. The same response reports the change through scope_updates and scope_set_version. A pull that fails validation writes no assignment state. Rebuild does not reconcile. It keeps its stored-scope check. For a scope that is no longer assigned, a request of an existing rebuild session or continuation returns 409 rebuild_restart_required, and a new rebuild request returns 400 invalid_request.

An operator registers one assignment function with synchro_register_assignment_function(p_function text, p_max_scopes integer DEFAULT 1000) and removes it with synchro_unregister_assignment_function(). Only synchro_operator can execute both functions. A second registration replaces the first in the same transaction. The registration function returns void. The registration actor is the outer session user.

The default p_max_scopes is 1000. A non-NULL value must be an integer from 1 through 1000. An operator can select SQL NULL to remove the assignment-result count limit. That choice requires evaluation of the complete DISTINCT result set. It changes no authorization, function restriction, definition hash check, result validation, or reconciliation rule.

A NULL p_function returns SQL NULL and changes no registration. This rule also applies when p_max_scopes is NULL or invalid.

assignment_function(p_user_id text)
RETURNS SETOF text

Registration rejects a function unless every rule below is true:

  • It is a plain LANGUAGE sql function with a parsed SQL-standard body.
  • It has exactly one text argument, no argument default, and no variadic argument. The argument is the canonical user ID.
  • It returns SETOF text.
  • It is STABLE and not SECURITY DEFINER.
  • Its search_path setting is exactly pg_catalog, synchro.
  • The registration actor owns it.
  • synchro_owner has EXECUTE on it, and PUBLIC does not.
  • It calls no function outside pg_catalog.
  • It depends on no relation in schema synchro.
  • The function owner has SELECT on each relation it depends on and USAGE on the schema of that relation.

These rules are the membership function rules with two differences. The function reads application relations directly, because assignment has no dependency propagation. The EXECUTE rule names synchro_owner, because connect and pull run as that role.

Connect and pull evaluate the function as its owner in a security-restricted operation. PostgreSQL uses the same model for index expressions, ANALYZE, and REFRESH MATERIALIZED VIEW. The function body never runs with the privileges of synchro_owner. Thus a body that reads a relation through pg_catalog.query_to_xml, an operator, or a view can read only the relations that its owner can read. The call and dependency rules alone cannot see these reads.

The registry stores the function OID, schema, name, max_scopes, and the SHA-256 of pg_get_functiondef in UTF-8. The server computes the definition with search_path set to pg_catalog, synchro, pg_temp, because the text of pg_get_functiondef depends on search_path. Registration computes the hash before and after it checks the rules. It rejects the function when the two hashes differ, so the stored hash identifies the checked definition. Connect and pull call the function once with the canonical user ID. Before the call, the server confirms that the OID exists and that the definition hash is unchanged. Each result must be non-null, must be nonempty after trim, and must not start with user:. These result rules apply to bounded and unbounded evaluation. If max_scopes is non-NULL, more than max_scopes distinct results is a failure. If max_scopes is NULL, evaluation returns the complete DISTINCT result set. A failure aborts the connect or pull transaction and returns 500 sync_integrity_failure. Health reports a missing function or a changed definition hash as a failed check.

A grant or revocation takes effect at the next successful connect or pull. Rebuild does not reconcile assignment, so a rebuild of a revoked scope is rejected.

Membership and impact functions must be schema-qualified LANGUAGE SQL, STABLE, and SECURITY INVOKER. Impact functions receive the captured old and new image of the dependency event. Membership functions may read only the extension’s schema-qualified synchronized and capture-dependency projection views for the worker’s current materialization transaction. They must not read live application relations. All referenced projections and fields must be declared dependencies. The functions must not depend on volatile values, session identity, session settings, temporary objects, sequence state, random values, wall-clock time, or dynamic SQL.

The registration validator verifies the exact return type and signature. It also verifies that every referenced relation and field is declared, every dependency has the required impact function, and every configured bound is positive. A function returning a null scope ID, an invalid scope ID, an incorrect result type, or more rows than its declared bound fails evaluation. Duplicate valid scope IDs are normalized as one scope membership result.

For each dependency, the registry records at least:

dependency_id
source_table_id
dependency_relation_id
dependency_registration_kind
dependency_field_ids
membership_function_id
impact_function_id
max_impact_rows

The dependency declaration is complete only when its impact function covers the old and new dependency state. For example, ownership removal, ACL revocation, parent reassignment, and deletion must identify every source row whose membership could be removed as well as every row whose membership could be added. A dependency change is not allowed to update only the new state.

For each source transaction, the worker first applies all captured source and dependency images to transaction-local projected state, collects the union of target row keys from old and new impact evaluation, and then evaluates every target membership function against the final projected state of that source transaction. It commits those projections, edge diffs, effects, and the transaction-end high-watermark atomically. Events in later source transactions cannot influence the result.

If an impact function, membership function, dependency lookup, or bound check fails, Synchro records a durable scope_evaluation_failed capture state and blocks capture. It never treats an evaluation failure as an empty scope set or an empty impact set.

The worker evaluates each membership and impact function as its owner in a security-restricted operation, with the same model as the assignment function. Thus the owner must have SELECT on each relation that the function depends on and USAGE on the schema of that relation. Registration rejects a function that breaks this rule. A later privilege change fails evaluation. synchro_prepare_projection_view grants SELECT on the view to the relation owner. synchro_operator membership gives USAGE on schema synchro_projection. A repeated call succeeds only when the stored view has the same physical relation, view name, and projected columns. Any other match on the relation or the view name fails with projection view identity is immutable and grants nothing. A projection view binds to the relation OID, so a later relation with the same name cannot supply rows to the view. A projection view returns rows only to a role that can read its source relation, and otherwise raises an error. Thus registration also rejects a function whose owner cannot read the source relation of a projection view that the function reads.

registry_generation is a positive, monotonic server generation for the active registered-relation configuration. It changes when an activated registration, function identity, dependency declaration, physical identity, or configured bound changes. Workers load and activate one complete registry generation, never a collection of independently refreshed rows.

membership_generation is a positive, monotonic generation for the materialization contract of an affected scope. It is persisted with the scope membership state and binds incremental cursors and rebuild sessions. Ordinary row changes under an unchanged membership contract do not advance this generation.

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

  1. Persist a validated pending registry generation and its intended membership generation.
  2. If the pending contract is fully representable from active synchronized and dependency projections, open one repeatable-read snapshot of those projections, read its durable transaction-end boundary, and stage the new edge set from exactly that state in bounded positive-size batches. If the pending contract introduces any uncaptured table, field, dependency, or projection value, use the logical-slot exported-snapshot baseline in ADR 001 to stage complete new projections and edges at its consistent point.
  3. Apply every source and dependency transaction after that boundary to the staged projections and edge set through the same registered functions and impact functions until a later transaction-end activation boundary is fully caught up. The main worker must be durable through the same boundary.
  4. Verify the staged set, atomically activate the registry and membership generations, and mark affected scopes for rebuild.
  5. Retain the prior generation for the lifetime required by active cursors, rebuild sessions, and retention lineage, then retire it through controlled cleanup.

No live membership edge set is deleted and rebuilt in place. No client receives partially backfilled membership. Activation invalidates only the affected scope generations and leaves unrelated scopes resumable.

A Class 3 manifest that needs historical values is pending until this complete projection bootstrap and catch-up succeeds. The manifest, registry generation, membership generation, staged projections, and affected-scope invalidation activate atomically at the shared boundary, so connect cannot request a rebuild from projections that lack the new table or field.

A schema manifest is an immutable stored value, not a live catalog query. Its body, SHA-256 schema_hash, positive schema_version, transition class, compatibility_floor, composition metadata, stable table IDs, and stable field IDs are inserted atomically in one transaction. No field of a published manifest is updated in place.

The client-visible body contains at least:

schema_version
schema_hash
parent_schema
transition_class
compatibility_floor
tables[]:
table_id
relation_id
name
composition
primary_key_field_id
fields[]:
field_id
name
type
nullable
writable

The schema-qualified physical identity remains registry metadata. Clients use logical IDs and do not infer server physical names.

parent_schema is null for the initial manifest and otherwise contains the exact parent version and hash. transition_class is initial, class_2, class_3, or class_4; Class 1 does not publish a new manifest. compatibility_floor is the positive version number of the earliest manifest in the current contiguous Class 2 lineage. The immutable manifest history maps that version to its exact hash. The server still evaluates the complete known lineage between the client manifest and the current manifest and does not infer compatibility from version ordering alone.

Class Meaning Connect result for known history
Class 1 Server-only behavior that does not change the client-visible schema. It changes registry or membership state, not the current client schema manifest. none when the schema reference is exact.
Class 2 Compatible additive client schema change that needs no historical materialization, such as a nullable field with no required backfill, a local index, or an empty new table. replace with the current inline manifest.
Class 3 Client schema or materialization change that requires authoritative rematerialization, including a new table with existing rows, an additive field with historical values, composition changes, and membership-materialization changes. rebuild_local only when the transition’s affected scopes intersect this client’s assigned scopes; otherwise replace.
Class 4 Incompatible structural or identity change, including a removed or renamed compatible field contract, incompatible type change, removed table, or changed primary-key identity. unsupported.

Class 1 records may be retained in configuration history, but they do not mint a different client schema reference. Class 2, Class 3, and Class 4 changes mint a new immutable manifest. A Class 4 boundary starts an incompatible lineage.

POST /sync/connect validates protocol compatibility before schema lineage. The fresh schema sentinel is exactly:

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

Any other zero or empty schema reference is malformed and returns HTTP 400 invalid_schema_reference.

The dispatch is exact:

protocol incompatible -> HTTP 426 upgrade_required
fresh sentinel with client_generation, schema_reset, or durable prior client state -> HTTP 400 invalid_schema_reference
server-confirmed fresh identity with fresh sentinel -> replace with inline manifest
exact current schema -> none
known compatible Class 2 history -> replace
known Class 3 history with affected assigned scopes -> rebuild_local with affected scopes
known Class 3 history with no affected assigned scope -> replace
unknown lineage or Class 4 history without explicit reset -> HTTP 200 unsupported action
explicit schema reset with assigned scopes -> rebuild_local with all assigned scopes
explicit schema reset with no assigned scope -> replace

For protocol version 3, fetch is not emitted. There is no follow-up schema endpoint in the connect flow.

The schema response object always contains version, hash, and action. Action-specific fields are exact:

Action Required fields Forbidden fields
none schema.version, schema.hash, schema.action schema_definition, affected_scopes
replace schema_definition containing the current immutable manifest affected_scopes
rebuild_local schema_definition containing the current immutable manifest, non-empty affected_scopes None of those required fields may be omitted.
unsupported schema.version, schema.hash, schema.action, schema.reason schema_definition, affected_scopes

affected_scopes is a deduplicated, bytewise sorted list of assigned scope_id values. It is authoritative for rebuild_local. A client applies the inline manifest before rebuilding only those scopes.

Every connect response also contains a scope_cursor_updates delta map for existing assignments. When a Class 2 or Class 3 schema action changes the manifest hash, the server authenticates each presented historical cursor. It must issue a replacement token at the exact same stream position under the current schema hash when the complete known transition path is compatible, the scope is unaffected by every Class 3 transition on that path, and the cursor’s user, client, client generation, stream, membership, retention, and resume-floor bindings remain current. Only an affected or otherwise stale existing scope receives null and requires rebuild. Added and removed assignments are represented only by the assignment delta, not duplicated in scope_cursor_updates.

This bridge does not acknowledge progress, reinterpret old rows, or accept an old-schema token on pull. It only re-MACs an authenticated safe position after the server proves that no transition changed that scope’s materialization. The client installs replacements atomically with the target manifest and migration finality. A client cannot edit, relabel, or manufacture a replacement. Thus a Class 2 action and the unaffected side of a Class 3 action can continue without rebuild while every affected scope remains explicitly invalidated.

An unsupported action is a successful HTTP 200 connect response so that a client can enter a typed upgrade-required state. It is not a request to guess a migration, to request fetch, or to continue normal sync.

A continuing authenticated client can recover after application upgrade by sending schema_reset: true with its current positive client_generation and the historical or unknown schema reference actually installed locally. The server accepts reset only when protocol 3 and the current minimum client runtime are satisfied. It returns the complete current inline manifest. If the client has assigned scopes, the action is rebuild_local, affected_scopes is exactly all assigned scopes in bytewise order, and each affected assignment has a null cursor. If it has no assigned scope, the action is replace.

The client replaces only synced-table materialization and protocol metadata required by the new manifest. It preserves local-only data, the complete authored mutation queue, sealed batch history, and inspectable outcomes. Queued intent remains governed by authored-schema compatibility and is never silently rewritten. schema_reset absent or false follows normal lineage dispatch. A fresh client cannot combine the fresh sentinel with schema_reset: true.

unsupported requires schema.reason equal to unknown_schema_lineage or incompatible_schema_transition. It does not include the current manifest body. The explicit reset request is the only protocol 3 path from that state to a current manifest.

Before client DDL, the native engine durably stores a versioned migration journal containing source and target references, the complete verified target manifest body, action, exact affected scopes, a versioned deterministic typed DDL and metadata operation plan, and phase. The plan is derived from and hash-bound to the persisted manifests and contains no unvalidated raw SQL. Recovery never fetches schema. It either executes the persisted plan while the source schema remains active, resumes push-first reconciliation and rebuild when the target schema and markers are durable, or preserves queues and local-only data and enters explicit error for any inconsistent physical or journal state.

Exact Current Schema Gate and Authored Mutation Schema

Section titled “Exact Current Schema Gate and Authored Mutation Schema”

Connect is the only operation that accepts a historical schema reference in order to select a schema action. Push and pull require an exact reference to the current immutable manifest:

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

If either comparison fails, push or pull performs no partial operation and returns HTTP 422:

{
"error": {
"code": "schema_mismatch",
"retryable": false,
"current_schema": {
"version": 42,
"hash": "current manifest hash"
},
"received_schema": {
"version": 31,
"hash": "received manifest hash"
}
}
}

The field names and error code are normative. The client must connect, execute the returned action, and then retry with the exact current schema reference.

Every queued mutation durably preserves the schema in which it was authored. Its durable and wire representation contains at least:

batch_id
mutation_id
table: logical table ID
pk
operation
base_version
client_version
columns[]: field_id, value
authored_schema: version, hash
local_order

The request-level schema remains the exact current schema. authored_schema records the immutable manifest used when local intent was captured. A queued mutation is not rewritten to a new schema by changing names, dropping unknown fields, or replacing its authored schema reference.

When replaying a mutation authored under an older manifest, the server compares the preserved table and field IDs with the current manifest. If every referenced field remains writable and compatible, the server may apply the mutation under the current request schema. Otherwise it returns this terminal, inspectable per-mutation outcome:

{
"mutation_id": "original mutation ID",
"status": "rejected_terminal",
"code": "schema_incompatible",
"message": "authored mutation cannot be represented by the current schema",
"retryable": false,
"authored_schema": {
"version": 31,
"hash": "authored manifest hash"
},
"current_schema": {
"version": 42,
"hash": "current manifest hash"
},
"incompatible_field_ids": ["logical field ID in bytewise order"]
}

The client retains that outcome and the original authored mutation for inspection until explicit client policy clears it. Successor local intent is not silently deleted or folded into the rejected mutation. Field values are not included in the error outcome.

Per-Scope Retention and Client Generations

Section titled “Per-Scope Retention and Client Generations”

Retention is per scope, not a global minimum changelog sequence. Each scope persists these fields:

scope_id
stream_generation
membership_generation
retention_generation
resume_floor: position_kind, commit_lsn, event_ordinal, and effect_ordinal when applicable

resume_floor is the inclusive minimum stream position from which a cursor can resume. A cursor at or above the current floor is resumable. A cursor below the floor is expired and requires a scope rebuild. The floor is durable even when a scope has no retained changelog entries. Empty changelog state is therefore not treated as proof that every historical cursor is valid.

retention_generation is the retention lineage generation. It changes only when the retention lineage itself is invalidated, such as a controlled lineage reset. Ordinary compaction moves resume_floor forward within the same lineage and does not change retention_generation. Consequently, ordinary floor movement does not invalidate a cursor at or above the new floor.

An incremental cursor binds the scope’s stream generation, membership generation, retention generation, stream position, exact schema hash, authenticated user, client ID, explicit client generation, issue time, and MAC. It is accepted by pull only when all bindings are current and its position is at or above the durable current floor. Connect may consume an otherwise valid historical-schema cursor only to issue the same-position current-schema replacement under the compatible unaffected-scope bridge above.

A well-formed cursor that is below the current floor or has an obsolete stream, client, schema, membership, or retention generation is stale, not forged. An obsolete schema binding can avoid rebuild only through the connect-time bridge above. Pull otherwise returns HTTP 200 with that scope in rebuild. A malformed, forged, or misbound cursor returns HTTP 400 invalid_request and never advances any scope.

client_generation is a positive server-issued generation scoped to the authenticated user and client_id. It is required in every push, pull, and rebuild request, returned by connect, and bound into every incremental cursor and rebuild token. A client persists it atomically with its assignments and cursors.

Connect may omit client_generation only for a fresh local client. When it is present, connect compares it with the durable client record and can renew an expired generation. Push, pull, and rebuild require the exact current positive generation. A client generation is expired through durable server policy before compaction stops retaining its resume obligation. Expiry does not reactivate the old generation.

The stale-client interval is a required finite positive deployment setting and defaults to 30 days. The server records last_acknowledged_at whenever a successful request presents a newer valid scope cursor. Before the first cursor acknowledgement, expiry uses the client’s created_at. Connect or push traffic without cursor acknowledgement does not refresh retention activity.

The client-generation gate applies to first-execution push work. Exact replay of an already completed sealed push batch occurs before that gate and returns the original stored outcome without source writes, even if the client generation has since been replaced. Pull and rebuild never bypass their current-generation gate.

When an expired client reconnects, connect issues a new client_generation, returns affected assignments with null cursors, and requires ordinary scoped rebuild. The client’s mutation queue remains durable and is evaluated through the authored-schema rules after connect. A push, pull, or rebuild request made with a missing, stale, or expired generation returns HTTP 409:

{
"error": {
"code": "client_generation_expired",
"retryable": false,
"current_client_generation": "current positive generation"
}
}

The client must reconnect before issuing another sync operation. This prevents a recycled client ID or stale local backup from reusing a different client’s cursor lineage.

Client-generation expiry is not irreversible client retirement. It does not delete batch or mutation idempotency ledgers and does not permit the scoped client ID to represent another installation. Irreversible retirement follows ADR 002 and permanently prevents that client ID from returning.

Compaction first expires clients under the rule above. For each scope, it then computes the minimum acknowledged position among nonexpired clients currently assigned that scope. It may delete only scope effects at or below that safe position. If no nonexpired client is assigned the scope, the safe position is the current durable scope high-watermark. An active rebuild session pins the history needed by its final cursor until that session completes or expires.

Effect deletion and monotonic resume_floor advancement commit atomically. If deletion fails, the floor does not move. If floor persistence fails, effects are not deleted. Compaction never derives the floor from the oldest remaining effect, an empty effect table, a global client minimum, or a checkpoint from an unrelated scope.

The following values are required positive integers and must not be silently defaulted, clamped, or converted from a non-integer value:

pull.limit
rebuild.limit
push.mutation_count
push.body_bytes
push.mutation_bytes
push.column_count
compaction.batch_size
backfill.batch_size
max_scope_fanout
max_impact_rows

pull.limit and rebuild.limit are fixed at 1..1000. Other configured bounds use the positive maximum registered for that administrative operation. A request or administrative action outside its applicable range fails before doing work:

{
"error": {
"code": "invalid_request",
"message": "request is invalid",
"retryable": false
}
}

Wire requests receive HTTP 400 invalid_request for an invalid limit. The message is bounded but is not a stable field-level limit description. Administrative SQL entry points return SQLSTATE 22023; a registration-bound failure uses invalid_registration with reason: "invalid_limit". A runtime membership result that exceeds a registered positive bound blocks capture with scope_evaluation_failed; it is not truncated.

The following codes and actions are the complete dispatch introduced by this decision. Messages may be localized or clarified, but status, code, required fields, and retryability are stable.

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, or a known identity misuses the fresh sentinel 400 invalid_schema_reference received_schema false
Server-confirmed fresh identity presents the fresh sentinel without generation or reset 200 schema.action: "replace" schema_definition n/a
Connect schema reference is exact 200 schema.action: "none" schema.version, schema.hash n/a
Known compatible Class 2 history 200 schema.action: "replace" schema_definition n/a
Known Class 3 history with affected assigned scopes 200 schema.action: "rebuild_local" schema_definition, affected_scopes n/a
Known Class 3 history with no affected assigned scope 200 schema.action: "replace" schema_definition n/a
Unknown lineage or Class 4 history without explicit reset 200 schema.action: "unsupported" schema.version, schema.hash, schema.reason n/a
Valid explicit reset with assigned scopes 200 schema.action: "rebuild_local" schema_definition, all assigned affected_scopes n/a
Valid explicit reset with no assigned scope 200 schema.action: "replace" schema_definition n/a
Push or pull schema is not exact current 422 schema_mismatch current_schema, received_schema false
Cursor is malformed, forged, or misbound 400 invalid_request No cursor detail is exposed. false
Rebuild token is expired or invalidated 409 rebuild_restart_required scope_id false
First-execution push, pull, or rebuild has a stale or expired client generation 409 client_generation_expired current_client_generation false
Registered relation or scope evaluation blocks capture 503 temporary_unavailable reason: "capture_blocked" true
Queued mutation references incompatible authored fields 200 schema_incompatible per-mutation outcome authored_schema, current_schema, incompatible_field_ids false
Pull or rebuild limit is absent, non-integer, outside 1..1000, or another bound exceeds its configured maximum 400 invalid_request No field-level limit members are required. false

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

Audit finding Disposition
S-08 Membership dependency propagation is explicit. Every external dependency has a complete old-and-new impact function. Revocation, reassignment, and deletion failures block capture rather than silently retaining or dropping membership.
S-09 Membership reconfiguration and backfill use staged edge sets, commit-safe boundaries, and generation invalidation instead of rewriting live membership behind valid cursors.
S-10 Schema manifests are immutable and atomic, composition is explicit, all four schema classes have one connect dispatch, and queued mutations retain authored schema identity without field omission.
S-12 Retention persists a per-scope resume floor and lineage generation, handles an empty retained log, expires clients through explicit client generations, compacts from acknowledged scope-local positions, and rejects nonpositive limits.
S-24 Registrations use immutable logical IDs and schema-qualified physical identity. Namespace collision rejection and relation-drift blocking replace bare-name and search-path lookup.

Bare names and automatic relation rebinding

Section titled “Bare names and automatic relation rebinding”

Using an unqualified relation name, resolving through search_path, or automatically rebinding after a rename, move, drop, key change, or replica identity change is rejected. It cannot distinguish a safe equivalent change from a different relation and can corrupt capture lineage.

Composite, partial, or arbitrary unique keys

Section titled “Composite, partial, or arbitrary unique keys”

Supporting composite keys, partial unique indexes, nullable keys, or arbitrary unique constraints is rejected for v0.3. It would require a distinct portable identity and mutation contract across every server and native client surface.

Arbitrary scope SQL and inferred dependencies

Section titled “Arbitrary scope SQL and inferred dependencies”

Ad hoc query strings, volatile functions, undeclared dependency reads, and impact functions that inspect only new state are rejected. They do not make membership invalidation or revocation falsifiable.

Deleting active membership edges and recreating them in place is rejected. Clients could observe partial membership and cursors could cross incompatible materialization states.

Updating a manifest body, hash, class, composition, or field list in place, or regenerating a historical manifest from the current catalog, is rejected. Historical schema references must resolve to the exact immutable body that was issued.

Deferring schema delivery to a separate fetch action is rejected. It introduces an avoidable state transition and permits a client to race a different manifest than the one selected by connect.

Silent queue rewriting or unknown-field omission

Section titled “Silent queue rewriting or unknown-field omission”

Dropping a queued field, guessing a rename, overwriting authored_schema, or silently folding successor intent into another mutation is rejected. A client must be able to inspect the exact local intent that became incompatible.

Using a global minimum checkpoint, deriving retention from the oldest retained log row, or rotating a retention generation on every normal compaction is rejected. Those approaches invalidate healthy per-scope cursors and fail when the retained log is empty.

Replacing zero or negative limits with defaults, clamping an over-limit request, or truncating a membership result is rejected. A caller must receive a deterministic invalid-limit error or capture must block.

  • Registry, membership, manifest, cursor, and client metadata require explicit immutable identifiers and generation-aware migrations.
  • Relation changes require an operational controlled-reconfiguration procedure and affected scoped rebuilds. Direct DDL changes to registered relations are intentionally not transparent.
  • Scope configuration requires more authoring discipline: dependencies and bounded impact functions must be declared and validated before activation.
  • Schema changes become deterministic at connect. Protocol 3 clients receive inline manifests and never perform schema fetch as a second handshake step.
  • Local queues preserve user intent across schema evolution, even when that intent becomes terminally incompatible.
  • Retention can compact per scope without invalidating a cursor at or above the new durable floor. Expired clients re-enter through explicit generation and scoped rebuild rather than reusing stale history.
  • Strict limits make invalid input and unsafe fanout visible to operators and clients instead of hiding it behind defaulting or truncation.