# ADR 0003 — Revision-based delta sync through a single write path

- Status: accepted
- Date: 2026-07-28

## Context

Clients must work fully offline and reconcile on reconnect, across multiple devices per user.
The server holds ciphertext, so **it cannot merge, validate or inspect item contents**. Every
conflict resolution decision therefore has to happen on a client.

## Decision

### One write path

All vault mutations go through `POST /vaults/{vaultId}/sync/push`. There are no per-entity
POST/PUT/DELETE endpoints. Reads are separate, list/get only, keyset-paginated.

### Change log and cursors

A per-vault `sync_change(seq bigserial, vault_id, entity_type, entity_id, operation,
revision, actor_user_id, occurred_at_utc)` log. Each entity denormalises `change_seq` so a
delta pull joins straight to the row.

Cursors are opaque and HMAC-tagged — `base64url("v1|{vaultId}|{seq}")` — so a tampered cursor
is rejected rather than silently mis-serving someone else's data.

### Push semantics

`expectedVersion` per operation. **HTTP 200 even on partial failure**, with a per-operation
status of `Applied | Conflict | Forbidden | Invalid | Duplicate`. Conflicting operations are
skipped, not aborted, and a `Conflict` returns the server's current row so the client can
merge and re-push. `opId` deduplication via `sync_operation_receipt` makes a retried push
exactly-once at operation granularity.

Caps enforced before the transaction opens: 500 operations per push, 8 MiB per batch,
256 KiB per item.

### The `bigserial` cursor gap — the reason for the advisory lock

`bigserial` hands out values **before** commit. Transaction A takes seq 5, B takes 6 and
commits first; a reader that advances its cursor to 6 **permanently misses 5**. This is silent
sync corruption that only manifests under concurrent writes to a single vault, which is
exactly the case least likely to be exercised by hand.

Mitigation: every push takes, as its first statement,

```sql
SELECT pg_advisory_xact_lock(hashtextextended(@vaultId::text, 0))
```

This serialises writers per vault, so sequence order equals commit order. Contention is
per-vault and a push batch is already one transaction. **It requires `Multiplexing=false` in
the Npgsql connection string** — the default; do not enable multiplexing.

## Consequences

- One place enforces revision, change-log and ACL invariants. That halves both the endpoint
  count and the authorization surface, which is the main reason for the single write path.
- Delta pull makes frequent polling cheap, so multi-device feels live; push notification over
  SSE or the existing WebSocket can layer on with polling as the fallback. **That has since been
  built — see [ADR 0012](0012-realtime-push.md)** — and nothing in this ADR changed to accommodate
  it. The socket carries a notice naming a vault and a sequence, whose answer is the delta pull
  above, so there is still exactly one path that applies a change; and polling is still what
  guarantees a pass rather than a legacy route kept for old clients.
- Conflict resolution is entirely client-side. The client retains a `BaseCiphertext` common
  ancestor and performs a field-level three-way merge for structured items, or creates a
  visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
  able to read tombstones, which is the one place that legitimately bypasses the soft-delete
  query filter — guarded by an explicit permission check.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
  Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
  item version used for conflict detection; `xmin` is the server-side optimistic guard and is
  **never exposed**, because it is not stable across `VACUUM FREEZE` and must not become a
  client cursor.

### Rejected

- **Snapshot-watermark cursors** (`pg_snapshot_xmin(pg_current_snapshot())`). Correct without
  locking, but materially harder to reason about and to test. Revisit only if per-vault lock
  contention shows up in practice.
- **Last-writer-wins.** Cheap, and it loses credentials. Unacceptable for this data.
- **Full pull on every sync.** Simple, but rules out the frequent polling that makes
  multi-device sync feel immediate.
- **Server-side merge.** Impossible by construction: the server cannot read the payloads.
