UNDA — Consent Register
Status: Consent architecture (§4) landed 2026-08-08 as blocker B5. The consents table is live (schema v3), and both onboarding-time and Settings-time grants/withdrawals write versioned rows.
Consent types (planned)
| Consent type | Legal basis backing it | Bundleable? | Default | Where captured | Persisted where |
|---|---|---|---|---|---|
health_data_processing | Art. 9(2)(a) explicit consent | Never bundled | Required to use the app's core function; requested during onboarding as its own step | consents table | |
cloud_sync | Art. 9(2)(a) — extends health processing to Supabase | Never bundled | Off | Settings → toggle "Cloud sync" | consents table |
notifications | Art. 6(1)(a) | Standalone | Off | Settings → Notifications | consents table |
product_analytics | Art. 6(1)(a) | Standalone | Off (not currently offered) | Settings → Analytics (planned) | consents table |
marketing_email | Art. 6(1)(a) | Standalone | Off | Not offered in v1 | consents table |
research_use | Art. 9(2)(j) (research) + Art. 6(1)(a) | Standalone; separate flow (§20) | Off | Not offered in v1 | consents table |
strava | Art. 6(1)(b) + Art. 9(2)(a) if activities carry health data | Standalone (per provider) | Off | Personalize screen | consents + integrations |
intervals_icu | Same | Standalone | Off | Personalize | Same |
garmin | Same | Standalone | Off | Personalize | Same |
apple_health | Same | Standalone | Off | Personalize | Same |
google_health_connect | Same | Standalone | Off | Personalize | Same |
Schema (shipped)
CREATE TABLE consents (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
consent_type TEXT NOT NULL,
policy_version TEXT NOT NULL, -- e.g. "privacy-policy@2026-08-01"
granted INTEGER NOT NULL,
granted_at TEXT NOT NULL,
withdrawn_at TEXT,
source TEXT NOT NULL, -- "onboarding" | "settings" | "personalize"
updated_at TEXT NOT NULL
);
CREATE INDEX idx_consents_profile_type ON consents(profile_id, consent_type);Rows are append-only. To withdraw a previously-granted consent we insert a new row with granted=0; the loader (ConsentRepository.loadActive()) reads the most-recent row per consent_type and treats that as the current state.
Rules
- RULE 12 — no reliance on a single
accepted_terms = truebool. Every optional processing needs its own row. - §4.3 Bundling prohibited — do not combine terms + health + marketing + research into one checkbox. Even the two "required" consents (account terms + health processing) are separate checkboxes on separate screens during onboarding.
- §4.4 Withdrawal — every consent row can be withdrawn via Privacy Center. Withdrawing
health_data_processingdisables cycle features and starts a grace-period deletion.
Current state (as of 2026-08-08)
health_data_processing— captured as its own step in onboarding (not bundled with anything else). Granted onContinue, sourceonboarding.notifications— recorded during onboarding when the user opts in.cloud_sync— captured when the user toggles the Cloud sync segmented control in Settings. Every flip writes a new row so on/off history is preserved.- All other consent types (
product_analytics,strava,intervals_icu,garmin,apple_health,google_health_connect) — schema is ready; UI to grant/withdraw ships alongside the feature that needs them.
The sync_enabled and notifications_enabled bools on profiles remain as fast-read preferences (used to render current toggle state); the consents table is the audit trail. Both are updated on the same flip.
Policy version tracking
Every new/withdrawn consent row records the exact version string of the Privacy Policy in force at that moment. Version identifiers follow: {document}@{effective-date}, e.g. privacy-policy@2026-08-01. Version history lives at /docs/legal/policy-history.md (to be added when the first policy is published).