Legal
Account Deletion & Data Retention
1. How a user deletes their account (the in-app flow)
Arcqtype satisfies the App Store Review Guideline 5.1.1(v) requirement (apps that support account creation must offer in-app account *deletion*, not just deactivation) with a self-service, in-app delete flow:
- The athlete app exposes a "Delete Account" row in Settings → Account, labeled *"Permanent. Removes all profile and training data."*.
- Deletion is a three-step confirmation: an initial "This will permanently delete your account and all data. This action cannot be undone." alert, a "Continue" step, then a final type-to-confirm alert requiring the user to type DELETE before the "Delete Forever" button fires.
- On confirmation, iOS calls DELETE /api/users/account and then signs the user out locally.
The deletion is immediate and self-service — no email round-trip, no support ticket, no waiting period before the data operation runs. This meets the App Store bar: a real delete (not a hidden/deactivate-only path), reachable from inside the app.
Open question for counsel: App Store guidance says deletion may be initiated in-app even if completion is finished elsewhere, but Arcqtype completes deletion synchronously. Confirm the user-facing copy ("cannot be undone") accurately reflects that there is no grace/undo window — see §6.
---
2. What the backend does on DELETE /api/users/account
The route handler runs three ordered steps:
1. Storage cleanup first — uploaded files are removed from Supabase Storage *before* the database delete, so a storage failure surfaces instead of leaving orphaned objects behind a successful DB wipe. 2. Database hybrid delete — calls the Postgres function account_delete_hybrid(target_user_id). 3. Auth identity delete — removes the Supabase Auth user via the Admin API.
Step 3 is treated as non-fatal: if the auth-user delete errors, it is logged but the data is already gone. A durable tombstone (see §4) prevents the still-valid auth token from silently re-bootstrapping a new account.
2.1 Storage buckets erased
cleanupUserStorage deletes every object the user uploaded across three athlete-keyed buckets, recursing one level for nested paths:
- Bucket — Path shape — Erased on delete?
- athlete-documents — <userId>/<documentId>.<sourceType> — Yes
- transcripts — <userId>/<timestamp>-<file> — Yes
- coach-attachments — <userId>/<conversationId>/<attachmentId><ext> — Yes
- team-plans, plan-documents — coach/team-keyed paths — No — intentionally retained (same coach/team may still need them for other athletes)
Storage cleanup failures are logged and surfaced but are non-fatal — the DB delete still proceeds, and the audit log captures the residue for manual follow-up.
---
3. The four-bucket hybrid retention policy
account_delete_hybrid (defined in migration 210, then re-defined in migration 243 to add the tombstone — arcqtype-api/migrations/210_account_delete_hybrid.sql, arcqtype-api/migrations/243_account_status_gate.sql:52-187) deliberately does not do a single blanket cascade. It splits the user's data graph into four buckets:
- Bucket — Policy — What it covers — Code
- 1. Private athlete-owned app data — CASCADE — hard delete — Profile, archetypes, workouts, goals, recovery logs, scoring rows, HealthKit health metrics (sleep/HRV/RHR/steps/weight), chat conversations + messages, projections, training prefs, and dozens more — ON DELETE CASCADE on users(id) across the schema — e.g. migrations/001_initial_schema.sql (many rows), migrations/048_health_metrics.sql:10 (health metrics), migrations/006_athlete_archetypes.sql:11
- 2. Cross-app relationship rows — Break the link (FK CASCADE removes the membership row) — Team memberships, guardian↔athlete links, coach↔athlete links — ON DELETE CASCADE on relationship tables, e.g. migrations/040_coach_mode.sql:30-31, migrations/037_sprint20_intelligence_amplification.sql:137-138. Downstream apps must render gracefully on a missing membership row
- 3. Public / recruiter-visible state — Immediate explicit DELETE — recruiter_watchlists, recruiter_notes, recruiter_contact_requests keyed on the deleted athlete — Explicit DELETE before the user-row cascade so recruiters see no ghost rows
- 4. Audit / safety / payment rows — Retain + anonymize (FK flipped to SET NULL, anonymized_at stamped) — athlete_events_log, chat_insights, subscriptions — user_id → SET NULL; anonymized_at stamped
3.1 Why bucket 4 is retained (the retention rationale, from code comments)
- athlete_events_log — append-only audit chain. A final user.account_deleted tombstone event is inserted *before* user_id is nulled so the audit trail records the deletion itself. Rows are then anonymized, not deleted.
- chat_insights — "Crisis-detection + safety insights must survive account deletion for abuse pattern review and ops escalation". Migration 211 was specifically added because the conversation-cascade was wiping these rows before the user_id SET NULL could fire; it changed chat_insights.conversation_id from CASCADE to SET NULL so safety insights actually survive.
- subscriptions — "RevenueCat reconciliation requires the row (revenuecat_customer_id, billing dates) to persist for refund/dispute handling". user_id goes NULL; the RevenueCat customer linkage and billing dates remain.
After anonymization these rows carry user_id = NULL plus an anonymized_at timestamp, severing the link back to the (now-deleted) identity while preserving the operational record.
Open question for counsel: Counsel must confirm that "anonymized" here meets the legal definition of anonymized/de-identified data under the applicable regime (GDPR Recital 26, CCPA/CPRA "deidentified," etc.). The code sets user_id = NULL but does not scrub free-text content inside chat_insights.content or athlete_events_log metadata. If retained safety insights contain re-identifying free text, "anonymized" may not hold and a true de-identification / minimization step may be required. See §7.
---
4. The durable tombstone — preventing account "zombies"
Because Supabase auth tokens can remain valid after the app-user row is deleted, the system writes a durable tombstone keyed on auth_id (and email) into user_account_tombstones during deletion.
On the next authenticated request, the auth middleware reads this tombstone. Behavior depends on the tombstone status:
- Status disabled or banned → request is blocked (no new app-user row is created).
- Status deleted → a fresh app-user bootstrap is permitted: a previously deleted user who signs in again starts a brand-new, empty account rather than being permanently locked out.
This means deleted is not a permanent lockout — it is a clean break. The old data is gone/anonymized; signing in again creates a new account from scratch.
Open question for counsel: The user_account_tombstones row retains the deleted user's email (lowercased) indefinitely for the blocklist/dedup function. Counsel should confirm indefinite retention of the email of a deleted account (potentially a minor's email) is defensible, or whether the tombstone email needs a retention window or hashing. There is no scheduled job that purges tombstones or anonymized rows anywhere in the codebase (verified by search) — retention is currently *indefinite*. See §6.
---
5. Account status states (disabled / deleted / banned)
Beyond user-initiated deletion, an account can be in one of four account_status values, constrained at the DB level to active | disabled | deleted | banned. Each blocked state has its own timestamp column: disabled_at, deleted_at, banned_at, plus an account_status_reason.
The access-state resolver treats any of disabled/deleted/banned (or the presence of the corresponding timestamp) as a hard block that zeroes all capabilities. The same blocked-status logic is enforced in the auth middleware before any route runs.
- Status — Set by — Data effect — App access
- active — Default on creation — None — Full (per role/age gate)
- disabled — Admin/ops (no user-facing trigger found in code) — Soft — row + data retained, disabled_at set — Blocked (403)
- banned — Admin/ops (no user-facing trigger found in code) — Soft — row + data retained, banned_at set — Blocked (403); tombstone blocks re-bootstrap
- deleted — User self-service via DELETE /api/users/account → account_delete_hybrid — Hard — user row deleted, buckets 1–3 erased, bucket 4 anonymized, tombstone written — Re-signin creates a fresh empty account
Open question for counsel: disabled and banned are soft states that retain all user data indefinitely while blocking access. The code paths that *set* disabled/banned were not found in the API routes/services (only the *enforcement* of those states) — these appear to be ops/admin operations performed directly against the DB. Counsel should confirm (a) whether a privacy-law access/erasure request can land on a disabled/banned account whose data is fully retained, and (b) the retention basis for keeping a banned user's full athletic + health data indefinitely.
---
6. Retention windows — current state
There is no automated retention-window enforcement in the codebase. A search across src/, migrations/, and scripts/ found:
- No scheduled purge of user_account_tombstones, anonymized audit/payment rows, disabled/banned accounts, or any other category.
- No retention_days / retention-policy configuration of any kind.
- The cron routes contain no deletion/retention job.
In practice, today's retention windows are:
- Data — Retention today (from code)
- Buckets 1–3 (private app data, relationship links, recruiter-visible state) — Erased immediately on delete
- Uploaded files (athlete-keyed buckets) — Erased immediately on delete (best-effort; failures logged)
- team-plans / plan-documents uploads — Retained (coach/team-owned)
- Bucket 4 (events log, chat_insights, subscriptions) — Retained indefinitely, user_id nulled + anonymized_at stamped
- user_account_tombstones (auth_id + email + status) — Retained indefinitely
- disabled / banned accounts (full data) — Retained indefinitely until/unless manually deleted
Open question for counsel — required decisions: 1. Define the retention schedule. The policy should state concrete windows (e.g., "anonymized safety insights retained for N years for abuse-pattern review"; "billing records retained for N years per tax/financial requirements"; "tombstone email retained for N months"). The engineering side currently has none — these are legal/business decisions that then need implementation as a scheduled purge job. 2. Grace / undo window. There is currently no grace period — deletion is immediate and irreversible. Confirm whether App Store / platform expectations or business preference call for a soft-delete grace window before the hard cascade runs. 3. RevenueCat / Apple subscription records. The subscriptions row is retained (anonymized) for refund/dispute handling, but Apple/RevenueCat hold their own billing records outside Arcqtype's control. The policy must disclose that subscription/billing history may persist with the payment processor and platform after Arcqtype-side deletion.
---
7. Minor-account deletion (COPPA / under-13 considerations)
Arcqtype serves minors under a 13+ launch posture. Current backend code still enforces guardian consent for under-13 saves as a backstop/divergence to align.
When a minor's account is deleted, the same account_delete_hybrid path runs — there is no separate minor-specific deletion flow in code. Notable consequences for minor data:
- HealthKit health metrics (sleep, HRV, resting heart rate, steps, active calories, workout minutes) are in bucket 1 → hard-deleted via ON DELETE CASCADE. Good for minimization.
- Guardian consent tokens are ON DELETE CASCADE → erased on deletion.
- COPPA audit log is ON DELETE CASCADE → erased on deletion.
- chat_insights safety/crisis insights for the minor are retained + anonymized (bucket 4), same as adults.
Open question for counsel — minor-specific, high priority: 1. COPPA consent-record retention conflict. COPPA (16 CFR §312) generally requires operators to retain records of verifiable parental consent and to delete a child's personal information when no longer needed. Here, coppa_audit_log and guardian_consent_tokens are hard-deleted on account deletion. Counsel must decide whether the consent *record* itself must survive the child's data deletion (to prove consent was obtained), which would require flipping these FKs from CASCADE to SET NULL / a retained anonymized record — the opposite of current behavior. 2. Who may delete a minor's account. The in-app delete is triggered by whoever is signed in on the device. COPPA gives the *parent* the right to direct deletion of a child's data. Confirm whether the policy and product need a guardian-initiated deletion path (e.g., via Arcqtype-Parent) distinct from the athlete-initiated one. No guardian-initiated deletion endpoint was found in code. 3. Retaining a minor's anonymized safety insights. Bucket 4 retains chat_insights (which can include crisis/self-harm/eating-disorder signals) indefinitely for a deleted minor, with user_id nulled but content not scrubbed. Counsel must weigh the safety/abuse-review rationale against COPPA/state-minor-privacy minimization duties, and confirm the de-identification is adequate for a child's sensitive data. 4. Minor email in tombstone. As in §4, the deleted minor's lowercased email persists in user_account_tombstones indefinitely. Confirm this is permissible or define a window.
---
8. What this document does NOT establish (must come from counsel)
- The legal retention schedule (concrete windows per data category) — none exists in code.
- Whether "anonymized" (currently just user_id = NULL, no content scrub) meets GDPR/CCPA de-identification standards.
- Whether COPPA consent records must survive child-data deletion (current code deletes them).
- Whether a guardian-initiated deletion path is legally required.
- Whether disabled/banned soft-retention has a lawful basis for indefinite retention.
- The user-facing disclosures about platform/processor-held billing records (Apple, RevenueCat) that persist outside Arcqtype.
- Jurisdiction-specific obligations (GDPR Art. 17 erasure timelines, CCPA/CPRA verifiable-request handling, FERPA if school data is implicated by the transcripts bucket).
---
Appendix A — Grounding citations
- Claim — File:line
- In-app Delete Account UI + type-DELETE confirm — Arcqtype/Arcqtype/Features/Settings/AccountDetailView.swift:51-62,:90-109; Core/ViewModels/SettingsViewModel.swift:61,:170-177
- iOS calls DELETE /api/users/account — Arcqtype/Arcqtype/Core/Services/API/SettingsAPI.swift:29-30
- Delete route: storage → DB hybrid → auth delete — arcqtype-api/src/routes/user.routes.ts:1306-1357
- Storage cleanup, athlete-keyed buckets, team/plan buckets excluded — arcqtype-api/src/services/user-storage-cleanup.service.ts:16-20,:27-104
- Four-bucket hybrid policy + rationale comments — arcqtype-api/migrations/210_account_delete_hybrid.sql:7-83
- Tombstone written; insights/events/subs anonymized; recruiter rows deleted — arcqtype-api/migrations/243_account_status_gate.sql:86-145
- chat_insights survives conversation-delete cascade — arcqtype-api/migrations/211_chat_insights_preserve_after_conversation_delete.sql:1-22
- account_status enum + timestamps + tombstone table — arcqtype-api/migrations/243_account_status_gate.sql:8-47
- Blocked-status enforcement — arcqtype-api/src/services/account-access-state.service.ts:44-62,:130-140; arcqtype-api/src/middleware/auth.ts:138-148,:252-265
- deleted tombstone permits fresh bootstrap — arcqtype-api/src/middleware/auth.ts:143-148
- HealthKit metrics CASCADE — arcqtype-api/migrations/048_health_metrics.sql:10
- Guardian consent tokens + COPPA audit log CASCADE — arcqtype-api/migrations/052_coppa_consent_tokens.sql:18-20,:31-37
- Under-13 guardian-consent enforcement — arcqtype-api/src/middleware/coppa.ts (header + checkUnder13Consent)
- No scheduled retention/purge job exists — Verified by search across src/, migrations/, scripts/ (no retention_days, no tombstone/anonymized purge)
- Original decision sources — shared-docs/reference/arcqtype-w8-review-packet.md (Decision 12), shared-docs/reference/arcqtype-w4-cross-app-fanout-audit.md