# Wundklar · sync protocol

The mutation envelope, the ledger with durable results and the per-kind resolution rules that keep offline work truthful.

Bedside work is created offline. The engine on the device batches mutations
with their dependencies, uploads their encrypted objects, pushes the batch
and then pulls new rows through a cursor that never skips a late-committing
transaction. The frozen protocol lives in
[`packages/api/src/features/sync`](https://github.com/CarlosZiegler/wundklare/blob/main/packages/api/src/features/sync/sync.service.ts);
the decision is
[ADR-0020](/docs/adr/0020-sync-protocol).

## Envelope [#envelope]

Every mutation on the wire is

```ts
{ id: UUIDv7, kind, entityId, patientId?, baseRev?,
  dependsOn: id[], occurredAt, deviceSeq, payload,
  objects?: [{ objectKey, variant, byteSize, sha256Cipher, wrappedDek, keyRef }] }
```

The handler for `kind` runs the mutation and writes the ledger row in **one**
transaction. Each result is one of `applied {rev, merged?}`, `duplicate`,
`conflict {theirs}`, `rejected {code}` (permanent, recorded in the ledger)
or `deferred {code}` (transient: `SYNC_DEPENDENCY_PENDING`, `OBJECT_PENDING`,
`SYNC_KIND_UNAVAILABLE`, timeouts).

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

## Per-kind behaviour [#per-kind-behaviour]

| Behaviour          | Example kinds                                                                          | Server rule                                                                                                                |
| ------------------ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| insert-only        | `patient.create`, `wound.create`, `photo.capture`, `assessment.record`, `visit.record` | replay via ledger; same id with a different payload hash → permanent `SYNC_ID_CONFLICT`                                    |
| field merge        | `patient.update`, `patient.contact`                                                    | disjoint fields merge; the same field changed concurrently → server order wins and the previous value is kept in the trail |
| conflicting        | `plan.propose`, `reminder.update` (due date)                                           | stale `baseRev` / `baseVersion` → `conflict { theirs }` and nothing written                                                |
| terminal-monotonic | `wound.close`, `reminder.update` (status)                                              | terminal state wins; later non-terminal changes are ignored                                                                |

Clinical records never update in place
([ADR-0028](/docs/adr/0028-immutable-clinical-records-and-privilege-matrix)):
a correction is a new row with `supersedes_id` and a reason.

## Staged objects [#staged-objects]

Encrypted bytes are uploaded first (`objects.stage` returns a presigned PUT
with the SHA-256 of the ciphertext). The referencing mutation's handler
calls `attachStagedObjects` inside its transaction and checks the same org
and device, the size and the checksum; missing → `OBJECT_PENDING`,
mismatch → `OBJECT_INVALID`
([ADR-0025](/docs/adr/0025-opaque-object-keys-and-staged-clinical-objects)).
`photo.capture` carries only the metadata and applies at once; `photo.attach`
follows when the upload is done.

## Pull and snapshot [#pull-and-snapshot]

`sync.pull` paginates through `(txid xid8, id)` strictly below
`pg_snapshot_xmin(pg_current_snapshot())` filtered by the patient scope rule
and the device's `device_scope`; a new patient on the scope triggers
`scopeAdded` so history older than the cursor arrives on the next snapshot,
and a leaving patient produces `scopeRemoved`.

`sync.snapshot` runs one `REPEATABLE READ` transaction and returns patients,
risks, contacts, wounds, the last 9 measurements, the last 2 assessments,
the active plan, prescriptions, stock, open reminders, the last photo
metadata, the day's schedule, the policy and the current access state.

## Authorization [#authorization]

Every push, pull and snapshot runs through the clinical boundary
([ADR-0031](/docs/adr/0031-one-clinical-authorization-boundary)): session
assurance, AVV, access state, device, role permission and patient scope.
Lists redact identifying ciphertext (`name_ct`, `address_ct`, contact name
and phone) for patients outside scope — hiding in the UI is not enforcement,
because every enrolled device holds the organization data key.

