Skip to content

Data Deletion — GDPR Right to Erasure

Objectuve users can erase their data two ways: through the public data-subject-rights form (an admin verifies identity and executes the deletion) or directly in-app from Settings (self-service, no admin involved). Both paths run the identical destructive cascade — Gdpr::UserDataCascade (rails_api/app/services/gdpr/user_data_cascade.rb) — as a single all-or-nothing transaction across every context that holds data tied to that user.

This page documents what "deleted" actually means, table by table — not every column is erased the same way, and getting that distinction wrong is the exact bug class this feature has already shipped three times (OBJ-2142, PR #2090; OBJ-2193, PR #2123; OBJ-2217, the self-service path drifting behind those two fixes before this page's own last update).

Requesting Deletion

Admin-initiated (data-subject-rights form)

Deletion requests come in through the public, unauthenticated intake form at GET/POST /privacy/request (rails_api/app/controllers/privacy_requests_controller.rb) — a server-rendered HTML form, not the Vue app, since it must work for someone who no longer wants to sign in. Submitting with request_type: deletion creates a GdprRequest (status: 'received') via Gdpr::SubmitRequest and starts a 72-hour SLA clock (due_by = received_at + 72.hours).

An admin (super_admin role, re-authenticated with a fresh step-up token) reviews the request from the admin dashboard's GDPR Queue and executes it. The executeGdprDeletion GraphQL mutation (rails_api/app/graphql/mutations/gdpr/execute_deletion.rb) requires:

  • super_admin — checked via authorize_admin_role!(:super_admin), not the looser admin? predicate.
  • A valid, single-use step-up token — missing or already-consumed tokens return { errors: ['Step-up required'] } so the admin UI can prompt for re-auth rather than fail generically.

The token is burned (StepUpTokenVerifier.burn_or_fail!) before the deletion cascade runs, so a retried mutation call can't re-run the cascade against the same authorization. Gdpr::ExecuteDeletion (rails_api/app/interactions/gdpr/execute_deletion.rb) resolves the target user, then hands off to Gdpr::UserDataCascade.run! for the actual destructive work, and finalizes the GdprRequest (status: 'fulfilled') inside the same transaction.

Self-service (Settings → Danger zone, OBJ-2183)

Signed-in users can delete their own account directly from Settings → Danger zone → Delete account (ionic_frontend/src/views/Settings.vue) — the App Store Guideline 5.1.1(v) remediation, and the primary path most users actually use. Tapping it shows a confirm/cancel alert ("This will permanently remove all your goals, streaks, and data. There is no going back."); confirming calls the deleteOwnAccount GraphQL mutation (rails_api/app/graphql/mutations/gdpr/delete_own_account.rb), which takes no arguments — it's hard-scoped to context[:current_user] and can never target another account.

There is no GdprRequest, no 72-hour SLA, and no step-up token on this path: the mutation only requires require_auth!, since the caller's own authenticated session is the identity proof — there's no separate admin identity to re-verify. Gdpr::DeleteSelfServiceAccount (rails_api/app/interactions/gdpr/delete_self_service_account.rb) runs the same Gdpr::UserDataCascade.run! as the admin path, then logs an AdminAction with action_type: 'gdpr_self_service_deletion_executed' (actor is the user themselves; no target, since there's no GdprRequest to point at — AdminAction#target is optional: true). On success the frontend clears local tokens and signs the user out; on failure it shows a generic 'Something went wrong deleting your account. Try again or contact support.' message — deliberately not e.message, so no internal error detail leaks to an unauthenticated-again user.

Clerk Identity Deletion (OBJ-3121)

Both deletion paths also delete the user's Clerk identity, outside the DB transaction, after Gdpr::UserDataCascade.run! commits. This closes a resurrection hole and completes erasure at the identity provider, not just in our own database.

The resurrection mechanism this closes: AuthenticateSessionToken#clerk_auth (rails_api/app/interactions/user_identity/authenticate_session_token.rb) looks up a user by clerk_user_id on every authenticated request; if none is found, sync_new_clerk_user calls UserIdentity::ClerkUserSync, which re-creates the users row via User.create!. Before this fix, nothing in the cascade deleted the Clerk identity itself, so any authenticated request that landed after erasure — a straggler ActionCable message, a retried request — could re-provision the erased user from Clerk's still-live profile. There's also an independent Art. 17 argument: Clerk holds the user's email and name, so erasure that stops at our own database and leaves that identity live at a processor is an incomplete right-to-erasure response regardless of whether resurrection is ever triggered.

Gdpr::ClerkIdentityDeleter (rails_api/app/services/gdpr/clerk_identity_deleter.rb) issues DELETE https://api.clerk.com/v1/users/<clerk_user_id> and is called from both Gdpr::DeleteSelfServiceAccount and Gdpr::ExecuteDeletion, in both cases:

  • After the DB cascade transaction commits, never inside it. A Clerk delete can't be rolled back, so it must never run inside ActiveRecord::Base.transaction.
  • Idempotent. HTTP 2xx and 404 both count as success — a re-run against an already-scrubbed row (clerk_user_id already nil) no-ops.
  • Fail-soft. Never raises. The DB erasure has already committed and can't be undone on a Clerk-side failure, so a Clerk outage doesn't fail the interaction — the user's data is still erased from our database either way. ClerkIdentityDeleter also no-ops (returns success) if clerk_user_id or ENV['CLERK_SECRET_KEY'] is blank.

On failure, a durable gdpr_clerk_identity_deletion_failed AdminAction is written (carrying only target_user_hash, never the raw clerk_user_id or email) and the error is captured to Sentry — this is the admin runbook's entry point for a manual follow-up, not a retryable step. Either way, the outcome is also recorded on the deletion's own success AdminAction metadata as clerk_identity_deleted: true|false.

Why the users Row Is Soft-Deleted, Not Hard-Deleted (D-01)

This is the single most misread thing about this feature: the users row itself is not hard-deleted. Gdpr::UserDataCascade (rails_api/app/services/gdpr/user_data_cascade.rb) — the shared cascade both Gdpr::ExecuteDeletion and Gdpr::DeleteSelfServiceAccount call into — scrubs every PII-bearing column on the row via update_columns (bypassing callbacks so it can't re-trigger normalization on a value it's setting to nil), then calls the ordinary paranoid user.destroy, which sets deleted_at and leaves the row in place.

The columns scrubbed (USER_PII_COLUMNS):

email, first_name, last_name, clerk_user_id, username,
password_digest, remember_token,
password_reset_token, password_reset_sent_at,
stripe_customer_id

This satisfies erasure because GDPR requires personal data to become unlinkable to the individual, not that every row referencing them vanish from the database. Once those columns are nulled, User.with_deleted.find(id) returns a row with no name, no email, no password, no Stripe identity — nothing that identifies a person — even though the row and its numeric ID persist. That persistence is what makes every "preserve-scrubbed" and "retained" disposition below actually satisfy erasure: the FK still points somewhere, but nothing recoverable lives at the other end.

The dependent: :destroy-on-acts_as_paranoid Trap

has_many :x, dependent: :destroy on an acts_as_paranoid model only soft-deletes x. dependent: :destroy calls the association's own #destroy, and paranoia overrides #destroy to be the soft-delete — it doesn't bypass the gem. A contributor reading has_many :enneagram_assessments, dependent: :destroy on User (rails_api/app/models/user.rb:56) will reasonably read that as "erasure coverage." It isn't. EnneagramAssessment.unscoped.where(user_id: user.id) still finds the row after user.destroy runs — deleted_at is set, nothing else changes.

This is the exact mechanism behind OBJ-2142's Defect A (PR #2090CoachConversation survived a "successful" deletion because its only cleanup was a paranoid dependent: :destroy) and behind three of the eight FKs OBJ-2193 had to fix: enneagram_assessments, feedback_posts, and feedback_comments all had — and still have — dependent: :destroy declared on User (user.rb:56,75,77), and all three are acts_as_paranoid. The fix wasn't to remove the association; it's a dedicated hard_delete_feedback_and_assessments! step (in Gdpr::UserDataCascade) that calls really_destroy! explicitly, and it must run before user.destroy fires — land it after, and really_destroy! finds nothing in the default scope because the paranoid callback already soft-deleted the rows out from under it, reproducing Defect A verbatim.

Rule of thumb for a new FK to users: dependent: :destroy alone is never sufficient evidence that erasure is covered. Check whether the target model is acts_as_paranoid — if it is, look for an explicit really_destroy! step in Gdpr::UserDataCascade, not just the association declaration.

Per-Table Disposition

Every column with a live foreign-key constraint to users falls into one of four categories. This table is generated from rails_api/app/services/gdpr/user_data_cascade.rb and the disposition lists in the shared example group rails_api/spec/support/shared_examples/gdpr_erasure_cascade.rb's schema-driven coverage spec (schema-driven FK-to-users erasure coverage, run against both Gdpr::ExecuteDeletion and Gdpr::DeleteSelfServiceAccount via it_behaves_like 'a GDPR erasure cascade') as merged — not from any planning document.

Hard-deleted (irrecoverable)

The row is physically removed (really_destroy! for paranoid models, delete_all/non-paranoid destroy otherwise). Nothing survives in Model.unscoped.

Table.columnMechanism
goals (whole goal tree — goal_events, milestones, etc. cascade with it)really_destroy!, via hard_delete_goal_tree!
integration_connections.user_id, habit_integration_mappings (cascades)really_destroy!, via hard_delete_integration_connections! — must run before the goal tree
coach_conversations.user_id (+ coach_messages cascade)really_destroy!, via hard_delete_coach_conversations!
mood_logs.user_iddelete_all
user_actions.user_iddelete_all
share_events.user_iddelete_all, via hard_delete_share_events!ShareEvent is acts_as_paranoid, so the step scopes .with_deleted first to also catch rows the user already soft-deleted themselves. This step also clears any share_attributions.share_event_id row referencing one of the erased user's events regardless of whose user_id that row carries — the erased user's ShareEvents are the FK target for a different user's attribution, so those rows must go first or the ShareEvent delete FK-violates (same defect class as Defect B/OBJ-2142, against share_events instead of goals)
share_attributions.user_iddelete_all, via hard_delete_share_attributions! — the recipient-side rows (this user's own attribution to someone else's share); ShareAttribution is acts_as_paranoid, so .with_deleted catches already-soft-deleted rows too
user_devices.user_idreally_destroy!
community_members.user_iddelete_all
ai_requests.user_id, data_imports.user_id, ally_invites.inviter_id, checkin_tokens.user_id, collective_goal_contributions.user_id, critical_path_plays.user_id, stim_xp_ledgers.user_id, partner_nudges.sender_id, goal_encouragements.user_id, goal_event_comments.user_id, goal_event_reactions.user_id, magic_codes.user_id, team_leaderboard_entries.user_id, team_memberships.user_id, team_pulse_responses.user_id, challenge_participants.user_id, seasonal_event_participants.user_id, user_details.user_id, user_goal_follows.user_iddelete_all, via hard_delete_leaf_owned_records! — leaf records with no further FK fan-out. AiRequest and DataImport are both acts_as_paranoid, so this step scopes .with_deleted first, same as the ShareEvent/ShareAttribution precedent above
enneagram_assessments.user_id, feedback_posts.user_id, feedback_comments.user_idreally_destroy!, via hard_delete_feedback_and_assessments! (OBJ-2193 — see the trap above; runs before user.destroy)
ai_employee_memories (subject match)really_destroy!, via hard_delete_ai_memories! (D-04)
admin_actions.actor_id, admin_roles.user_id, coach_interactions.user_id, community_follows.user_id, community_suggestions.user_id, content_reports.reporter_id, event_attendees.user_id, feedback_votes.user_id, post_reactions.user_id, user_allies.user_id, user_allies.ally_id, user_feed_items.user_id, user_notifications.user_id, user_photos.user_id, user_sign_ins.user_idPre-existing dependent: :destroy on a non-paranoid model — a real hard delete, no trap (the trap only applies to paranoid targets, see above)
post_comments.user_id.destroy, but PostComment isn't paranoid, so it's a real hard delete despite reading like a soft-delete step (see D-03 below)

Soft-deleted + anonymized (D-03 — authored community content)

The row survives as a paranoid soft-delete (deleted_at set), but is functionally erased because the FK it carries now points at the scrubbed users row from D-01 — no author name, email, or identity is reachable through it.

Table.columnMechanism
community_posts.user_id.destroy (paranoid)
goal_event_encouragements.user_id.destroy (paranoid)

Preserve-scrubbed cross-owner (decided 2026-08-06, OBJ-2193 Q1)

No cascade step at all — the row is untouched, FK intact, pointing at the target's now-scrubbed users row. User declares no owner-side association for any of these four, so this was already the live behavior; OBJ-2193 confirmed it was intended and pinned it with a spec rather than changing code.

Table.columnNotes
teams.billing_owner_idTeam survives GDPR deletion of its billing owner
collective_goals.created_by_idCollective goal survives deletion of its creator
community_challenges.creator_idChallenge survives deletion of its creator
team_invites.invited_by_idInvite record survives deletion of the inviter (separate column from accepted_by_id, below) — with one exception, see Team invites: preserve-scrubbed, with one exception (D-08)

Every spec for this category asserts both halves of the disposition: the row survives with the FK intact, and every USER_PII_COLUMNS field on the referenced (with-deleted) user is nil — driven off the constant, so a future PII column is automatically covered.

Team invites: preserve-scrubbed, with one exception (D-08)

team_invites.invited_by_id is preserve-scrubbed like the other three cross-owner FKs above — the invite row survives, and invited_by_id keeps pointing at the scrubbed users row from D-01. invited_by_id itself stays NOT NULL; this does not reopen the OBJ-2142 FK disposition decided above.

But Teams::AcceptTeamInvite#invite_currently_acceptable? (rails_api/app/interactions/teams/accept_team_invite.rb:88-90) checks only status == 'pending', expires_at, and used_count — it never checks the inviter. Left untouched, a still-pending invite from an erased inviter would keep granting a team seat on behalf of an account that no longer exists. The public, unauthenticated teamInvitePreview query (query_type.rb) and the invite email (TeamInviteMailer) also read invite.invited_by.first_name, which raises NoMethodError once the inviter is soft-deleted (paranoia's default scope makes invite.invited_by resolve to nil).

Gdpr::UserDataCascade#revoke_pending_team_invites! (D-08, called from run! immediately after nullify_incidental_references!) closes this: any invite still status: 'pending' from the erased user is flipped to revoked, inside the same cascade transaction, for both Gdpr::ExecuteDeletion and Gdpr::DeleteSelfServiceAccount. accepted/revoked/expired invites from the same user are left byte-identical, and a pending invite from a different, living user is untouched. This is a status change, not a nullify or a hard-delete — the one FK in this table whose disposition depends on the referencing row's own state (status), not just the target user's.

A one-shot, idempotent rake task, team_invites:revoke_orphaned (rails_api/lib/tasks/team_invites.rake), backfills invites that were stranded pending before this cascade step shipped (User.unscoped.where.not(deleted_at: nil) — a plain User.where.not(...) returns the empty set here, since User combines acts_as_paranoid with its own explicit default_scope). Safe to re-run; a second run revokes 0 and logs it. Both invite.invited_by reads that would otherwise raise on an erased inviter (QueryType#team_invite_preview, TeamInviteMailer#team_invite_email) are also now nil-safe (&.) as defense-in-depth, so a pending invite that somehow reaches either code path before revocation degrades instead of 500ing.

Retained under lawful basis (decided 2026-08-06, OBJ-2193 Q2, D-06)

Table.columnBasis
payment_records.user_idGDPR Art. 17(3)(b) — tax/accounting retention. The table carries no PII columns (amounts, currency, status, provider/Stripe IDs only); its only identity link is user_id, pointing at a users row already scrubbed by D-01 (including stripe_customer_id).

What "retained" means in practice: payment_records still has dependent: :destroy declared on User (user.rb:79), and PaymentRecord is acts_as_paranoid — unlike the three OBJ-2193 hard-deletes, this association was deliberately not overridden with an explicit really_destroy! step, so the pre-existing dependent: :destroy fires as normal when user.destroy runs. The row physically persists with deleted_at set, outside the live default scope, and reachable via PaymentRecord.with_deleted. Anyone running accounting queries against payment history needs to query with_deleted, not the default scope, to see records for erased users.

Nullified (incidental references)

The deleted user was staff/actor on someone else's record, not its owner — the reference is cleared, the record survives.

Table.column
admin_roles.granted_by_id
ai_usage_events.user_id
content_flags.reviewer_id
content_reports.reviewer_id (separate column from reporter_id, which hard-deletes — see above)
gdpr_requests.fulfilled_by_id
team_invites.accepted_by_id (separate column from invited_by_id, which preserve-scrubs — see above)
user_allies.last_nudge_sent_by_id

AdminAction rows targeting the deleted user (as opposed to actor_id, above) follow a fifth path outside this FK table entirely: they're preserved for the platform's own audit trail, with target_user_hash stamped and their before_state/after_state PII scrubbed via Gdpr::AdminActionScrubber (D-05) — the audit log survives, the PII inside it doesn't.

Accepted Collateral: Erasing a User Can Delete Other Users' Content

Hard-deleting feedback_comments/feedback_votes before feedback_posts, and feedback_posts before user.destroy, is ordered deliberately (comments first, so FeedbackComment's counter_cache: :comment_count decrements correctly on posts owned by other users). But really_destroy!-ing the erased user's own feedback_posts recursively destroys that post's remaining feedback_comments and feedback_votes — including ones left by users who are not being erased.

This is intentional, not a bug: feedback_comments.feedback_post_id is NOT NULL, so a comment thread has no meaning without the post it's attached to, and there is no other way to preserve it without hard-deleting a comment authored by someone still on the platform. If a popular thread ever needs to survive its original poster's erasure, the only real route is a sentinel "Deleted user" account plus scrubbing the post's own text — a schema and product change, not something this cascade does today.

The same shape of tradeoff exists for goals: another user's CollectiveGoalContribution or GoalEncouragement on the erased user's goal is not deleted — it's nullified (the goal reference cleared, their own row kept), because unlike a feedback thread, those records carry meaning independent of the goal they pointed at.

Extending the Cascade — Contributor Note

Adding a new table with a foreign key to users does not automatically get erasure coverage. Because both deletion paths call the same shared service and share the same spec coverage, there are exactly two places to register a new FK — not one per interaction:

  1. Add a cascade step to Gdpr::UserDataCascade#run! (rails_api/app/services/gdpr/user_data_cascade.rb) — hard-delete, nullify, or a documented exception, matching one of the categories above — and add the table name to Gdpr::UserDataCascade::CASCADE_TABLES. If the target model is acts_as_paranoid, dependent: :destroy alone is not a cascade step (see the trap above) — you need an explicit really_destroy! call, ordered before user.destroy. This one step covers both Gdpr::ExecuteDeletion and Gdpr::DeleteSelfServiceAccount, since both delegate to it — there's no separate self-service cascade to keep in sync.
  2. Add a matching count to Gdpr::CascadePreview::NEW_CASCADE_COUNT_SPECS (rails_api/app/interactions/gdpr/cascade_preview.rb) so the admin dry-run preview doesn't under-report what's about to happen. A spec asserts Gdpr::CascadePreview::COVERED_TABLES.sort == Gdpr::UserDataCascade::CASCADE_TABLES.sort — add one without the other and that spec fails.
  3. Add a disposition entry to the shared example group spec/support/shared_examples/gdpr_erasure_cascade.rb's schema-driven FK-to-users erasure coverage describe block (the known set built from already_covered_fks / preserve_scrubbed_cross_owner_fks / retained_lawful_basis_fks / hard_delete_fixtures / nullify_fixtures). This spec reads the live schema, not a hand-list — a new FK with no disposition entry fails it by name, for both interactions, since execute_deletion_spec.rb and delete_self_service_account_spec.rb both run it via it_behaves_like 'a GDPR erasure cascade'.

Skip any of these three and GDPR erasure silently misses the new table — on both deletion paths, not just one. This shared-service-plus-shared-spec structure is what OBJ-2217 closed: before it, the self-service path hand-duplicated the admin cascade and had already drifted behind two rounds of FK-erasure-gap fixes (OBJ-2142/OBJ-2193) before anyone caught it.

See Also

Last updated: 2026-09-15 — added data_imports.user_id to the Hard-deleted table (hard_delete_leaf_owned_records!, v4.64 Phase 2 CSV import staging, OBJ-3822)

Previously: 2026-09-15 — added seasonal_event_participants.user_id to the Hard-deleted table (hard_delete_leaf_owned_records!, v4.63 Phase 6 seasonal events, OBJ-3812)

Loading…