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;
the decision is
ADR-0020.
Envelope
Every mutation on the wire is
{ 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
| 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):
a correction is a new row with supersedes_id and a reason.
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).
photo.capture carries only the metadata and applies at once; photo.attach
follows when the upload is done.
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
Every push, pull and snapshot runs through the clinical boundary
(ADR-0031): 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.