ADR 004: Membership, Schema, and Retention
ADR 004: Membership, Schema, and Retention
Section titled “ADR 004: Membership, Schema, and Retention”Status
Section titled “Status”Accepted, 2026-07-18.
Context
Section titled “Context”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.
Decision
Section titled “Decision”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.
Logical and Physical Relation Identity
Section titled “Logical and Physical Relation Identity”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_schemaphysical_relationphysical_relation_oidreplica_identitycapture_key_columnprimary_key_field_id for synced registrationsprimary_key_typephysical_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.
Registered-relation validation
Section titled “Registered-relation validation”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".
Primary key and replica identity contract
Section titled “Primary key and replica identity contract”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:
stringintint64string 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, integerint64 bigintstring text, character varying, character, uuid, inet, cidr, macaddr, macaddr8, int4range, int8range, numrange, int4multirange, int8multirange, nummultirangeThe 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.
Deterministic Scope Membership
Section titled “Deterministic Scope Membership”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.
Function signatures
Section titled “Function signatures”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 textIt 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_refp_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
Section titled “Scope assignment”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 textRegistration rejects a function unless every rule below is true:
- It is a plain
LANGUAGE sqlfunction with a parsed SQL-standard body. - It has exactly one
textargument, no argument default, and no variadic argument. The argument is the canonical user ID. - It returns
SETOF text. - It is
STABLEand notSECURITY DEFINER. - Its
search_pathsetting is exactlypg_catalog, synchro. - The registration actor owns it.
synchro_ownerhas 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.
Function restrictions
Section titled “Function restrictions”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_idsource_table_iddependency_relation_iddependency_registration_kinddependency_field_idsmembership_function_idimpact_function_idmax_impact_rowsThe 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 and Membership Generations
Section titled “Registry and Membership Generations”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.
Staged membership backfill
Section titled “Staged membership backfill”Rule changes, dependency changes, composition changes, and controlled relation reconfiguration use this sequence:
- Persist a validated pending registry generation and its intended membership generation.
- 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.
- 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.
- Verify the staged set, atomically activate the registry and membership generations, and mark affected scopes for rebuild.
- 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.
Immutable Schema Manifests
Section titled “Immutable Schema Manifests”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_versionschema_hashparent_schematransition_classcompatibility_floortables[]: table_id relation_id name composition primary_key_field_id fields[]: field_id name type nullable writableThe 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.
Schema classes
Section titled “Schema classes”| 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.
Protocol 3 Schema Dispatch
Section titled “Protocol 3 Schema Dispatch”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_requiredfresh sentinel with client_generation, schema_reset, or durable prior client state -> HTTP 400 invalid_schema_referenceserver-confirmed fresh identity with fresh sentinel -> replace with inline manifestexact current schema -> noneknown compatible Class 2 history -> replaceknown Class 3 history with affected assigned scopes -> rebuild_local with affected scopesknown Class 3 history with no affected assigned scope -> replaceunknown lineage or Class 4 history without explicit reset -> HTTP 200 unsupported actionexplicit schema reset with assigned scopes -> rebuild_local with all assigned scopesexplicit schema reset with no assigned scope -> replaceFor 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.
Explicit incompatible-schema reset
Section titled “Explicit incompatible-schema reset”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.
Crash-safe local schema application
Section titled “Crash-safe local schema application”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_versionrequest.schema.hash == current.schema_hashIf 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_idmutation_idtable: logical table IDpkoperationbase_versionclient_versioncolumns[]: field_id, valueauthored_schema: version, hashlocal_orderThe 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_idstream_generationmembership_generationretention_generationresume_floor: position_kind, commit_lsn, event_ordinal, and effect_ordinal when applicableresume_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.
Explicit client generation and expiry
Section titled “Explicit client generation and expiry”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.
Scope-local compaction
Section titled “Scope-local compaction”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.
Strict Positive Limits
Section titled “Strict Positive Limits”The following values are required positive integers and must not be silently defaulted, clamped, or converted from a non-integer value:
pull.limitrebuild.limitpush.mutation_countpush.body_bytespush.mutation_bytespush.column_countcompaction.batch_sizebackfill.batch_sizemax_scope_fanoutmax_impact_rowspull.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.
Error and Action Dispatch
Section titled “Error and Action Dispatch”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 Dispositions
Section titled “Audit Dispositions”| 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. |
Rejected Alternatives
Section titled “Rejected Alternatives”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.
Live delete-and-rebuild backfills
Section titled “Live delete-and-rebuild backfills”Deleting active membership edges and recreating them in place is rejected. Clients could observe partial membership and cursors could cross incompatible materialization states.
Mutable or live-generated manifests
Section titled “Mutable or live-generated manifests”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.
Schema fetch in protocol 3
Section titled “Schema fetch in protocol 3”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.
Global or inferred retention floors
Section titled “Global or inferred retention floors”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.
Silent limit normalization
Section titled “Silent limit normalization”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.
Consequences
Section titled “Consequences”- 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.