Status: accepted · Date: 2026-11-06 · Related: ADR-0015, ADR-0017, ADR-0020
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
- No account for family sync.
POST /api/familymakes 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 asAuthorization: Bearer, never in a URL. - Invites are QR codes: single use, valid 24 hours, granting
edit(push and pull) orview(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. - 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 (
buildFamilySnapshotin@varu/preparedness/outbound: people, pets, goal, member names, the split shopping list) sealed with AES-256-GCM, the family id as additional data. - 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.
- Storage. Sealed bytes go to the family-sync R2 bucket (
FAMILY_SYNC_BUCKET, EU jurisdiction, same endpoint and credentials asSTORAGE_*); 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_tenanthas no grant.
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
- 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
editaccess 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 carriesthrough, 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).