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
The server, the device engine and every offline feature share one frozen sync contract (plan §1.7). It freezes four shapes:
- The wire envelope:
Mutation,PushInput/PushResult,PullInput/PullResult,SnapshotInput/SnapshotResult,IdBlock. - The mutation catalog: 26 kinds, each with its zod payload schema and a behaviour descriptor (
insert-only·field-merge·conflicting·terminal-monotonic). - The row schemas: one per syncable table of plan §1.2, delivered inside
sync.pull.changes[].rowandsync.snapshot.rows[].row. - The handler signature:
MutationHandler<K>runs an Effect program against a drizzle transaction and aClinicalContext, returning anApplyResult(applied | duplicate | conflict) or a taggedApplyFailure { 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
{
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 | 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
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;mergedlists the fields a field-merge kind wrote.duplicate— ledger replay byid.conflict { theirs }— stalebaseRevon 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
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
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
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
- 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 discriminatedmutationSchemakeys each kind to its payload schema; a kind without a payload schema is a type error at build time.
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
- A single
patient.updatethat 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
conflictstate across kinds. Rejected:wound.closeis terminal (never comes back),plan.proposeconflicts need both versions kept for the resolve UI, andpatient.updatemust merge disjoint fields silently. One behaviour tag per kind drives the right branch.