Sync Contract v2

Sync Contract v2 is an opt-in, provider-neutral interface for synchronizing a least-privilege Project Alpha projection. Project Alpha remains authoritative. Existing version 1 keys and pagination remain compatible.

The route is disabled unless APP_SYNC_CONTRACT_V2_ENABLED=true. Keep that setting false until every item in the production gate has been completed.

Foundation status: the v2 snapshot and event primitives are suitable for consumer contract development and mutation-adapter work. Do not enable a production consumer until every covered mutation writes its resource version and event in the same transaction and webhook delivery/reconciliation has passed the production gate described below.

Authentication and route

Use a dedicated API key with only ops.sync.read.

GET /api/v2/ops/snapshot?limit=100
Authorization: Bearer <api-key>

The route is parallel to /api/v1/ops/snapshot; callers opt in by selecting the v2 URL. Existing API keys, v1 response fields, and v1 pagination do not change.

Identity

source_instance_id is the stable UUID of this Project Alpha installation. It is seeded from Project Alpha’s existing installation identity when present and otherwise generated once by the migration.

Consumers identify a resource by the tuple:

(source_instance_id, resource.type, resource.id)

Local numeric IDs are serialized as strings. Email addresses, names, codes, and other editable attributes are never identities.

The existing v1 snapshot exports the stored public_id of organizations, clients, and projects as an additive field on each resource row. The v2 snapshot and upsert projection export that same value in data.public_id. This is the same source-issued, 32-character lowercase hexadecimal identifier used by the portal projection, not a hyphenated installation/session UUID. Numeric id and relationship fields such as organization_id and client_id retain their original values and types. Build an explicit, source-scoped mapping between these identifiers; never infer a link from equal names, email addresses, or unqualified IDs from another installation. Client email and phone remain scalar fields; this addition does not introduce a contacts array or change their existing null handling.

Migration 0062_client_portal_foundation.sql backfilled these IDs once, makes them non-null and unique within each entity table, and assigns IDs when new rows are inserted. Snapshot reads do not create or repair IDs. A missing or malformed stored ID fails either export rather than publishing a fabricated mapping. Older producers without this additive field remain unmapped until upgraded and synced.

Adding this field changes the v2 content fingerprint of an already-observed resource. The contract deliberately reports sync_state_out_of_date rather than silently replacing that fingerprint; such a deployment requires an explicit versioned compatibility transition. Before enabling v2 in production, protect global event commit ordering: an auto-increment sequence can be allocated before another transaction commits a higher sequence, so a consumer checkpointing that higher value could miss the earlier transaction’s late event. Per-resource locks and a plain MAX(sequence) high-water read do not prevent this. A transaction-held global sequencing lock with consistent lock ordering, or an equivalent proven mechanism, must precede the upgrade. Validate it with a real MySQL three-connection test (two writers and a reader), including delayed commits, rollbacks, checkpoint advancement, and replay convergence. Do not enable v2 in production or synthesize mappings while these prerequisites remain incomplete.

Snapshot bootstrap

The first response creates an API-key-bound snapshot session:

{
  "contract_version": "2.0",
  "source_instance_id": "11111111-1111-4111-8111-111111111111",
  "snapshot": {
    "id": "22222222-2222-4222-8222-222222222222",
    "generated_at": "2026-07-30T18:00:00.000000Z",
    "expires_at": "2026-07-30T18:30:00.000000Z",
    "high_water_sequence": "41"
  },
  "items": [],
  "next_cursor": null,
  "request_id": "request-example-0001"
}

When next_cursor is non-null, request the next page with both values returned by the server:

GET /api/v2/ops/snapshot?limit=100&snapshot_id=<uuid>&cursor=<opaque>

Do not decode, synthesize, or edit cursors. Sessions expire after 30 minutes and are bound to the API key that created them. Restart from the first request after expiry.

Expired sessions are removed when a new snapshot begins. Each API key may hold at most ten active sessions, preventing abandoned pagination sessions from growing without bound.

high_water_sequence is the last committed v2 event when the session began. After reading all pages, a consumer begins event reconciliation strictly after that sequence. Sequence and version values are decimal strings and must be handled as arbitrary-precision integers.

Pages do not hold a database transaction open across HTTP requests. Instead, resource versions and the event high-water mark provide convergence: if a resource changes during pagination, its event carries the same or a later version. Consumers must ignore an event version they have already applied.

Snapshot resources

The foundation allowlist contains:

The allowlist excludes financial amounts and terms, payment-provider IDs, authentication data, public/private tokens, private storage paths, uploaded files, and arbitrary configuration/custom-field blobs.

Project Alpha lifecycle values remain canonical. Consumers must preserve unknown future values rather than rejecting or silently remapping them.

Resource and event envelopes

Snapshot items use:

{
  "resource": {
    "type": "job",
    "id": "40",
    "version": "4"
  },
  "data": {}
}

The provider-neutral event envelope is:

{
  "contract_version": "2.0",
  "source_instance_id": "11111111-1111-4111-8111-111111111111",
  "sequence": "42",
  "event_id": "33333333-3333-4333-8333-333333333333",
  "occurred_at": "2026-07-30T18:05:00.000000Z",
  "resource": {
    "type": "job",
    "id": "40",
    "version": "4"
  },
  "action": "upsert",
  "data": {}
}

delete events carry data: null. Event IDs are stable UUIDs. Delivery is at-least-once, so consumers deduplicate by event_id and apply per-resource versions monotonically. Gaps in the global sequence are allowed.

The machine-readable schema is sync-contract-v2.schema.json. Golden consumer fixtures are under tests/fixtures/sync-contract-v2.

Transaction consistency

The v2 event service rejects calls made outside an active database transaction. A covered mutation must:

  1. update the authoritative Project Alpha row;
  2. build the exact allowlisted projection used by the snapshot;
  3. update sync_resource_state; and
  4. append sync_event_log;

all before committing the same transaction.

The snapshot seeds unseen resource states at version 1. Once seeded, it fails closed with sync_state_out_of_date if a resource differs from its recorded state, exposing an uninstrumented mutation instead of silently serving an unreconcilable snapshot.

Errors

V2 errors use:

{
  "success": false,
  "code": "invalid_request",
  "message": "Human-readable summary.",
  "request_id": "request-example-0001"
}

Important codes are invalid_limit, invalid_request, snapshot_invalid, sync_state_out_of_date, schema_out_of_date, and snapshot_failed.

Production gate

Before an external application consumes v2 in production:

Inbound write APIs, conflict resolution, file transfer, and provider-specific fields are outside this foundation.