Production test-account cleanup
Manual, repeatable procedure for fully removing a one-off account created directly against the production Clerk instance and the production database — e.g. a real sign-in run needed to isolate an environment-specific bug that a staging build can't reproduce (OBJ-2570, prompted by OBJ-2563's Android OAuth investigation). There is no automation for this: the only existing purge automation (DemoData::ClearDemoDataJob, the smoke-goal-cleanup.yml / smoke-activity-cleanup.yml crons) is hard-scoped to the single shared demo@objectuve.com account and does not generalize to an arbitrary account. This page covers the manual path only — no new mutation, admin UI, or cron is in scope.
When to use this
- A test/investigation required a real sign-in against production Clerk (dev-instance divergences — device verification, shared OAuth credentials — don't hold on staging, so some bugs only reproduce against prod keys).
- The account was created solely for that investigation and needs to be fully removed afterward, on both the Rails and Clerk sides.
Not for real user deletion requests. Those go through Data Deletion's documented GDPR paths (admin-initiated via GdprRequest + step-up token, or user self-service). This procedure reuses one piece of that same machinery (see below) but skips the GdprRequest record and audit trail those paths create — appropriate for an internal test account, not for a real person's data.
What a production sign-in creates
Traced from the real auth path (rails_api/app/interactions/user_identity/authenticate_session_token.rb:39-52, rails_api/app/interactions/user_identity/clerk_user_sync.rb):
- The first authenticated request auto-provisions a local
Userrow before any explicit sync call —AuthenticateSessionToken#clerk_authlooks the user up byclerk_user_id; if none exists it fetches the Clerk profile and callsUserIdentity::ClerkUserSync, which doesUser.create!(...)and grants a starter streak-freeze (Gamification::GrantStarterFreeze— aUserActionrow). - The frontend's
syncUsermutation (rails_api/app/graphql/mutations/sync_user.rb) adds more state on top: aUserSignInrow (UserIdentity::RecordSignIn), afirst_sign_inUserAction(line 32), and an enqueuedGamification::CheckBadgesJob(line 39) that can award furtherUserActionbadge rows. - A corresponding account on the production Clerk instance — nothing in this repo's Rails side touches Clerk to remove it.
User (rails_api/app/models/user.rb:31) is acts_as_paranoid — a plain destroy only sets deleted_at and leaves the row (and its PII: email, name, clerk_user_id) in the production DB. Genuine removal needs a hard delete on the User row and its dependents, plus a separate Clerk-side deletion.
Procedure
1. Identify the account
You need one of: the account's email, its production clerk_user_id, or its public_id.
user = User.find_by(email: '<email>'.downcase)
# or: user = User.find_by(clerk_user_id: '<clerk_user_id>')
# or: user = User.public_find('<public_id>')
user.public_id
user.clerk_user_id2. Open a production Rails console
Use the same enkidu-rails-console-production Cloud Run job documented in AI Runbook § Re-firing alerts:
gcloud run jobs execute enkidu-rails-console-production --region=us-central1 --wait3 (optional). Preview the footprint before deleting
Gdpr::CascadePreview (rails_api/app/interactions/gdpr/cascade_preview.rb) — the same dry-run the admin GDPR queue uses — returns per-table counts for a user_id (accepts integer id or public_id) with no writes:
Gdpr::CascadePreview.call(user_id: user.id).detailsA brand-new test account should show near-zero counts everywhere except users: 1 and whatever the sign-in itself created (user_actions, maybe user_sign_ins). A large or unexpected count is a signal you have the wrong account — stop and re-check step 1.
4. Run the deletion cascade
Don't hand-roll the hard-delete of each dependent — Gdpr::UserDataCascade (rails_api/app/services/gdpr/user_data_cascade.rb) already does exactly that, in FK-safe order, and is the single source of truth both GDPR deletion paths share. It already covers everything a fresh sign-in creates: the starter-freeze grant, the first_sign_in action, and any badge grants are all UserAction rows, hard-deleted via hard_delete_user_actions!; the UserSignIn row is hard-deleted via User's own dependent: :destroy (it isn't a paranoid model, so that association is a real delete, not the paranoid-dependent::destroy trap documented in Data Deletion).
Call the self-service interaction (rails_api/app/interactions/gdpr/delete_self_service_account.rb) directly from the console — it takes only user_id, requires no GdprRequest or step-up token (those gate the separate admin GraphQL mutation, not this interaction), and is the same code path a user hits from Settings → Danger zone:
Gdpr::DeleteSelfServiceAccount.call(user_id: user.id)This hard-deletes goals, integration connections, coach conversations, feedback/assessments, ai_employee_memories, and every other owned table in Gdpr::UserDataCascade::CASCADE_TABLES, then PII-scrubs the users row (email, first_name, last_name, clerk_user_id, username, etc. → nil) and soft-deletes it (deleted_at set) — see Data Deletion § Why the users Row Is Soft-Deleted for why the row itself survives at this point.
As of OBJ-3121, this call also deletes the account's Clerk identity — after the DB cascade commits, fail-soft (a Clerk-side failure doesn't fail this call or the DB erasure). See Data Deletion § Clerk Identity Deletion for the mechanism. Step 7 below covers verifying that and the manual fallback if it failed.
5. Hard-delete the now-scrubbed users row
Unlike a real GDPR request, a test account has no compliance reason to leave an anonymized row behind — remove it entirely, the same really_destroy! mechanism DemoData::ClearDemoDataJob uses for the shared demo account (rails_api/app/jobs/demo_data/clear_demo_data_job.rb):
user.reload.really_destroy!By this point step 4 has already hard-deleted or nullified nearly everything the row was associated with, so really_destroy!'s own cascade through User's dependent: :destroy associations (paranoia 3.1.0) has little left to do beyond removing the row itself — but it does still have an effect: for every dependent: :destroy association whose target model is itself paranoid, really_destroy! really-destroys the entire association, not just rows step 4 already soft-deleted. That sweeps up any soft-deleted D-03 residue (e.g. community_posts) step 4 left anonymized-but-present, and — unlike the GDPR cascade — it also hard-deletes payment_records (User#payment_records, dependent: :destroy, PaymentRecord is paranoid), which step 4 deliberately leaves alone. See the Caveats section below before running this step against an account that went through checkout.
6. Verify removal (Rails side)
User.unscoped.exists?(email: '<email>'.downcase) # => false7. Verify the Clerk-side account is gone
As of OBJ-3121, step 4's Gdpr::DeleteSelfServiceAccount call already deletes the Clerk-side account for you (Gdpr::ClerkIdentityDeleter, post-commit, using the clerk_user_id captured before the DB cascade ran) — this step is now a verification, not a manual action. Check the Clerk Dashboard → the production instance → Users → find by email; it should be gone.
If it's still there, the Clerk call failed (fail-soft — a gdpr_clerk_identity_deletion_failed AdminAction is written for it, see Data Deletion § Clerk Identity Deletion) or CLERK_SECRET_KEY was unset in this console's environment. Delete it manually, using the clerk_user_id captured in step 1 before the Rails deletion ran:
Dashboard: Clerk Dashboard → the production instance → Users → find by email → Delete user.
API (matches the existing Faraday/Bearer pattern this repo already uses for Clerk calls, e.g. rails_api/lib/tasks/clerk_migration.rake:58 and rails_api/app/graphql/mutations/sync_user.rb's backfill_from_clerk):
CLERK_SECRET_KEY=$(gcloud secrets versions access latest --secret=clerk-secret-key)
curl -s -X DELETE "https://api.clerk.com/v1/users/<clerk_user_id>" \
-H "Authorization: Bearer $CLERK_SECRET_KEY"clerk-secret-key is the production secret (clerk-secret-key-dev is staging — see Deployment § Secrets); double-check you're reading the production one before running this against a real account.
Caveats
payment_recordsdo NOT survive this procedure, unlike the real GDPR flow.Gdpr::UserDataCascadealone retainspayment_recordsby design (GDPR Art. 17(3)(b) tax/accounting exception, D-06 in Data Deletion) — but step 5'sreally_destroy!cascades throughUser#payment_records(dependent: :destroy, paranoid target) and hard-deletes them anyway. That's the correct outcome for a test account (full removal, no compliance reason to retain anything) but the opposite of D-06's real-user retention policy — don't run step 5 against an account you actually needpayment_recordshistory for. A test account created purely for a sign-in investigation shouldn't have any.- This is not the GDPR compliance flow. No
GdprRequestis created and nofulfilled-status audit record exists afterward — theAdminActionlogged byGdpr::DeleteSelfServiceAccount(action_type: 'gdpr_self_service_deletion_executed') is the only trace. Don't use this procedure for an actual person's deletion request. - Out of scope by design: no new GraphQL mutation, admin UI, or scheduled cron. If ad hoc production test accounts become a recurring need beyond one-off investigations, that's a separate follow-up to evaluate automation, not something this runbook covers.
See also
- Data Deletion — the full GDPR erasure cascade this procedure reuses, table-by-table disposition
- AI Runbook § production console access — the
enkidu-rails-console-productionjob used in step 2 - Deployment § Secrets —
clerk-secret-keyvsclerk-secret-key-dev
Last updated: 2026-09-01 — step 4's Gdpr::DeleteSelfServiceAccount call now also deletes the Clerk-side account (OBJ-3121); step 7 is now a verification + manual fallback, not the primary mechanism