# Sync protocol

The repository's architectural decision, rationale and consequences.

# Sync protocol

**Status:** accepted · **Date:** 2026-10-31 · **Owner:** t11-sync-contract-freeze

**Enforcement:** `packages/api/src/features/sync/*.test.ts` cover every acceptance criterion of ticket 11 (catalog + registry completeness, CONFLICTING / FIELD_MERGE sets, ciphertext payload rejection, syncable-table coverage, push/pull/snapshot JSON round-trip, deferred `SYNC_KIND_UNAVAILABLE` stub); `pnpm --filter @wundklare/api exec vitest run src/features/sync` runs them; `scripts/leaf-modules.test.mjs` keeps `sync.contract.ts`, `sync.mutations.ts` and `sync.rows.ts` leaf modules (web and mobile import them). The push/pull handlers themselves land with tickets 21/23; the client engine with ticket 16; the per-feature `apply<Kind>` with each feature's ticket.

## Decision [#decision]

The server, the device engine and every offline feature share one frozen sync contract ([plan §1.7](../wundklar/IMPLEMENTATION_PLAN.md)). It freezes four shapes:

1. The **wire envelope**: `Mutation`, `PushInput/PushResult`, `PullInput/PullResult`, `SnapshotInput/SnapshotResult`, `IdBlock`.
2. The **mutation catalog**: 26 kinds, each with its zod payload schema and a behaviour descriptor (`insert-only` · `field-merge` · `conflicting` · `terminal-monotonic`).
3. The **row schemas**: one per syncable table of plan §1.2, delivered inside `sync.pull.changes[].row` and `sync.snapshot.rows[].row`.
4. The **handler signature**: `MutationHandler<K>` runs an Effect program against a drizzle transaction and a `ClinicalContext`, returning an `ApplyResult` (`applied | duplicate | conflict`) or a tagged `ApplyFailure { severity: "transient" | "permanent" }`.

A registry maps every kind to its feature's `apply<Kind>`. Until a feature ships, its handler is a stub that returns the transient `SYNC_KIND_UNAVAILABLE` — the device keeps the mutation and retries.

### Mutation envelope [#mutation-envelope]

```ts
{
  id: string           // UUIDv7 — client-generated, unique per organization
  kind: MutationKind
  entityId: string     // the row the mutation targets (not necessarily the patient)
  patientId?: string   // scope hint; the server authoritatively resolves
  baseRev?: number     // for field-merge and conflicting kinds
  dependsOn: string[]  // other mutation ids that must land first
  occurredAt: string   // client wall clock, ISO 8601 with offset
  deviceSeq: number    // monotonic per device; the client tiebreaker
  payload: PayloadFor<K>
  objects?: ObjectStageRef[]  // required by handlers that attach bytes
}
```

### Behaviour by kind [#behaviour-by-kind]

| Behaviour          | Kinds                                                                                                                                                                                                                                                                                                                                                                                                           | Server rule                                                                                                                                                                                                                  |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| insert-only        | `patient.create`, `patient.risks`, `consent.record`, `consent.revoke`, `wound.create`, `wound.classify`, `photo.capture`, `photo.attach`, `measurement.record`, `measurement.correct`, `assessment.record`, `assessment.correct`, `visit.record`, `stock.consume`, `stock.adjust`, `stock.reorder`, `prescription.record`, `prescription.requestFollowUp`, `report.create`, `report.deliver`, `conflict.record` | Client id; replay via ledger. Same id + different payload hash → permanent `SYNC_ID_CONFLICT`.                                                                                                                               |
| field merge        | `patient.update`, `patient.contact`                                                                                                                                                                                                                                                                                                                                                                             | Payload carries changed fields with their base value; disjoint fields merge; same field changed concurrently → server order wins, previous value kept in the trail, result `merged` (no user conflict — matches product R6). |
| conflicting        | `plan.propose`, `reminder.update` (due date)                                                                                                                                                                                                                                                                                                                                                                    | Stale `baseRev` → `conflict { theirs }`, nothing written.                                                                                                                                                                    |
| terminal-monotonic | `wound.close`, `reminder.update` (status)                                                                                                                                                                                                                                                                                                                                                                       | Terminal state wins; later non-terminal changes ignored.                                                                                                                                                                     |

`reminder.update` is dual-behaviour on purpose: the dispatcher classifies by which field moved. The catalog records the concurrency-sensitive half (`conflicting`) so planners pessimistically assume the hard case.

### Push [#push]

`sync.push({ deviceId, mutations[≤100] })`. Each mutation runs its own transaction with: boundary (`clinicalMutationAccess`, ticket 17) → handler → ledger row. Results per mutation (plan §1.7):

- `applied { rev, merged? }` — new revision; `merged` lists the fields a field-merge kind wrote.
- `duplicate` — ledger replay by `id`.
- `conflict { theirs }` — stale `baseRev` on a conflicting kind.
- `rejected { code, permanent: true }` — permanent failure (`SYNC_DEPENDENCY_REJECTED`, `SYNC_ID_CONFLICT`, `SYNC_SCHEMA_INVALID`, `SYNC_PERMISSION_DENIED`, `SYNC_SCOPE_DENIED` (forged envelope or cross-patient reference), `PATIENT_OUT_OF_SCOPE` (the patient is outside the user's clinical scope), `OBJECT_INVALID`); the ledger records the rejection and dependants surface permanently in the sync centre.
- `deferred { code }` — transient failure (`SYNC_KIND_UNAVAILABLE`, `SYNC_DEPENDENCY_PENDING`, `OBJECT_PENDING`, `SYNC_RATE_LIMITED`, timeouts); nothing is written to the ledger, the device retries with exponential backoff.

`dependsOn`: each id must be `applied` or `duplicate` in the ledger, or earlier in the batch; a ledger rejection permanently rejects the dependant (`SYNC_DEPENDENCY_REJECTED`) even in a later batch.

The response carries `deviceState`, `accessState`, `policyVersion` and ID blocks (≥ 10 free numbers per kind) so the device can keep enrolling patients and wounds offline.

### Staged objects [#staged-objects]

Bytes never become clinical records on their own (plan §1.7, ticket 22). `objects.stage` writes an intent and returns a presigned PUT with the SHA-256 checksum; the referencing mutation's handler calls `attachStagedObjects` in its transaction (same org and uploader device, HEAD size and checksum match); missing → deferred `OBJECT_PENDING`; mismatch → rejected `OBJECT_INVALID`; unattached stages expire after 7 days (`objectsCleanup`).

Photos split into `photo.capture` (metadata, applies at once so measurements and assessments do not wait for Wi-Fi) and `photo.attach` (objects, follows when the upload is done).

### Pull [#pull]

`sync.pull({ deviceId, cursor?, limit ≤ 500 })` → `{ changes, cursor, hasMore, scopeAdded, scopeRemoved, deviceState, accessState }`. Each change is `{ op: upsert|delete, entity, id, rev, patientId, row? }` where `row` matches `ROW_SCHEMAS[entity]`; delete tombstones omit the row.

**Cursor** = opaque `(txid, id)`; the query is `where (txid, id) > cursor and txid < pg_snapshot_xmin(pg_current_snapshot())` and the scope filter, ordered by `(txid, id)`. The `pg_snapshot_xmin` guard means pull never skips a late-committing transaction (ticket 23).

Tombstones come from DELETE triggers (retention, ticket 74). Scope = §1.3.1 rule, evaluated per pull against `device_scope`: new patients land in `scopeAdded` (the device snapshots them, so history older than the cursor arrives); leaving patients land in `scopeRemoved` (device purges unless pending).

### Snapshot [#snapshot]

`sync.snapshot({ deviceId, date?, patientIds? })` runs one REPEATABLE READ transaction: patients, risks, contacts, wounds, last 9 measurements, last 2 assessments, active plan, prescriptions, stock, open reminders, last photo metadata with crop/thumb keys (ghost image), the date's schedule, policy, access state; redaction applied; `watermark = pg_snapshot_xmin(pg_current_snapshot())`. Pull continues from the watermark: anything not in the snapshot has `txid ≥ xmin` and is delivered; rows delivered twice are ignored by `rev`.

### Trust and typing [#trust-and-typing]

- **Ciphertext fields are typed as `wk1.<keyRef>.<iv>.<ct>` strings** (`CIPHERTEXT_PATTERN`); a payload that smuggles plaintext into a ciphertext column is rejected at the schema. Column parity between row schemas and the drizzle schema is enforced by ticket 21.
- **Payload schemas are `.strict()`** so unknown fields fail parsing instead of leaking across versions.
- **The envelope is the one source of truth for `kind`** — the discriminated `mutationSchema` keys each kind to its payload schema; a kind without a payload schema is a type error at build time.

### Scoped namespaces and backfill [#scoped-namespaces-and-backfill]

The device engine (tickets 15, 23) partitions its local store per scope `wk:<serverOrigin>:<userId>:<organizationId>`. A patient entering scope triggers `scopeAdded` on the next pull; the device issues a `sync.snapshot` for that id so history older than the cursor arrives. Leaving triggers `scopeRemoved`; the device purges unless pending.

## Alternatives considered and rejected [#alternatives-considered-and-rejected]

- **A single `patient.update` that writes the whole row.** Rejected: offline nurses update different fields of the same patient concurrently; whole-row writes make every late update a conflict screen (R6).
- **Server-generated ids.** Rejected: offline-created rows must keep the id the device chose, so references land even before the row is pushed (§1.2 names clinical ids UUIDv7 for exactly this reason).
- **Durable queue (D1, Workflows) for background application.** Rejected (ADR-0022): a leased Postgres queue in the one cron tick is cheaper, debuggable, and the sync path is interactive — the dispatcher must answer the device in-request.
- **A single mutable `conflict` state across kinds.** Rejected: `wound.close` is terminal (never comes back), `plan.propose` conflicts need both versions kept for the resolve UI, and `patient.update` must merge disjoint fields silently. One behaviour tag per kind drives the right branch.

