# Family sync with anonymous device keys

The repository's architectural decision, rationale and consequences.

# Family sync with anonymous device keys

**Status:** accepted · **Date:** 2026-11-06 · **Related:** [ADR-0015](0015-household-data-lives-on-the-device.md), [ADR-0017](0017-eu-hosting.md), [ADR-0020](0020-account-optional-household-app.md)

**Enforcement:** `packages/api/src/features/family/family.procedures.test.ts` (server stores ciphertext only, invite expires after 24 h, no tenant reads a family), `packages/preparedness/src/outbound.family.test.ts` (the snapshot excludes health data and the vault), `apps/mobile/src/features/family/family.sync.test.ts` (only ciphertext leaves the phone), `family.flow.test.ts`, `family.shared.test.ts` and `family.merge.test.ts` (members' edits, T38).

ADR-0020 expected family sync to be the first place the app asks for an email. T24 allows "Better Auth or anonymous device keys". We chose device keys; this amends ADR-0020's consequence.

## Decision [#decision]

1. **No account for family sync.** `POST /api/family` makes a family whose only member is the calling phone (`owner`). Each phone holds a random 256-bit token; the server stores only its SHA-256, like the municipality opt-in. The token travels as `Authorization: Bearer`, never in a URL.
2. **Invites are QR codes**: single use, valid 24 hours, granting `edit` (push and pull) or `view` (pull only; the app shows the plan). Only the owner invites and removes; anyone may leave; the owner leaving deletes the family and its blobs. At most 6 phones.
3. **End-to-end encryption.** The owner's phone makes a random AES-256 family key. It goes from phone to phone inside the invite QR, never to the server. A change is the home's snapshot (`buildFamilySnapshot` in `@varu/preparedness/outbound`: people, pets, goal, member names, the split shopping list) sealed with AES-256-GCM, the family id as additional data.
4. **Health and vault never enter a snapshot.** The builder copies an allowlist and drops the health area and the rules a health flag brings (medication, powered-device backup, pet medication). Vault documents are not an input at all.
5. **Storage.** Sealed bytes go to the family-sync R2 bucket (`FAMILY_SYNC_BUCKET`, EU jurisdiction, same endpoint and credentials as `STORAGE_*`); Postgres holds only the object key, size, sequence and author. The newest 20 changes per family are kept. All four tables (`family_group`, `family_member`, `family_invite`, `family_change`) are system-only (`asSystem(db, "family.sync", …)`); `app_tenant` has no grant.

## Why not Better Auth [#why-not-better-auth]

An account would add an email and a User row per family member to a server that otherwise knows nothing about a home, and the key would still need an out-of-band channel. The QR already is that channel.

## Consequences [#consequences]

- A lost phone cannot be recovered into the family; the owner removes it and invites again. A leaked QR within its 24 hours lets one phone join; the owner sees the member count and can remove it.
- The family key never rotates: a removed phone that kept old snapshots can still read them, and could read new ones if it got the blobs (it cannot: its token is gone). Key rotation on removal is future work.
- Only the owner pushes snapshots (whole, sent only when they changed). Since T38 a member with `edit` access pushes **edits** instead (`buildFamilyEdits`: tick/untick and new buyer by row id, only for rows of the received snapshot, so never a health row). They are queued on the member's phone (SQLCipher settings) while offline and sent sealed like any change. The owner's phone merges them in sequence order (`mergeFamilyEdits`): a member's tick also records the row's quantity as stock, so readiness rises (F6); an untick takes back only that stock. Health rows are never changed from outside. A snapshot carries `through`, the last change it merged, so a member keeps showing its own edits until a snapshot includes them.
- Production needs a second EU-jurisdiction bucket (see `docs/STATUS.md`).

