feat(account): deletion = legal retention, not erasure (PR3) #164

Merged
developer merged 12 commits from feature/email-relay-pr3 into development 2026-07-03 12:19:13 +00:00
Owner

PR3 — Account deletion = legal retention, not erasure

Deletion deactivates + anonymises live surfaces + retains a legal dossier — a legal
obligation (illegal content / law-enforcement) that overrides GDPR-style erasure. All
decisions owner-locked (2026-07-03).

Model (owner Q1=A, Q2=B)

  • Retention journal retained_identities (append-only): every credential detachment —
    unlink, email change, delete — writes a row (reason) before the live identity is
    removed, so the dossier keeps the full timeline while the (kind, external_id) frees for
    a new account to reuse. No soft-delete → no auth footgun.
  • Deletion (internal/accountdelete.AnonymizeAndTombstone): journal + remove the
    credentials → tombstone (accounts.deleted_at) → scrub live display_name
    [Deleted] (unspoofable — the editable-name rule forbids brackets) with the real name
    kept in deleted_display_name → anonymise seat snapshots → drop the account's own
    friendships/blocks/invitations/friend-codes/drafts/pending-codes. Chat, feedback and
    complaints are kept
    (the tombstone keeps their no-cascade FKs valid).
  • Orchestration: resign active games (opponents not stranded) → drop all-robot games
    (no human opponent; children cascade) → tombstone → revoke sessions.
  • Step-up: a mailed code (purpose=delete, no deeplink — a stray click must not delete)
    for an email account; else a typed DELETE phrase (anti-impulse).
  • Last login: accounts.last_login_at/last_login_ip stamped on the cold-load profile
    fetch, throttled to once an hour.
  • TTL: a two-year reaper (from the event) purges the whole dossier — the journal,
    and a deleted account's feedback thread + dossier PII; chat and the tombstone stay.

Surface

  • Migration 00007 (additive: retained_identities + 4 nullable accounts columns) — jet
    regen (accounts + retained_identities only). No contour wipe (expand-contract).
  • Endpoints /user/delete/{request,confirm}; gateway account.delete.{request,confirm} ops
    • fbs AccountDeleteConfirm/AccountDeleteRequestResult; branded ru/en delete email.
  • UI: Profile delete control + step-up dialog; terminal AccountDeleted screen
    ("Учётная запись удалена") + Close (telegramClose/vkClose; web = none); client/transport/
    mock/codec; ru/en i18n.
  • Admin: user-detail Deletion & retention panel (last login, tombstone, the credential
    journal) + an operator Delete-user action (same orchestration).

Tests

  • Integration: retention journal (unlink/change/delete), last-login throttle, deletion core
    (journal + tombstone + credential reuse), all-robot drop vs human keep, step-up (code +
    no-email), dossier readers, TTL reaper boundary + feedback/PII purge.
  • Codec wire (delete confirm + request-result); Chromium/WebKit e2e (delete → terminal).

Locally verified: Go build/vet/gofmt/unit, gateway, full integration package, codegen
idempotent, UI check/test/build, e2e (social/smoke/onboarding). PR4 (Grafana + admin
alerts) follows.

## PR3 — Account deletion = legal retention, not erasure Deletion **deactivates + anonymises live surfaces + retains a legal dossier** — a legal obligation (illegal content / law-enforcement) that overrides GDPR-style erasure. All decisions owner-locked (2026-07-03). ### Model (owner Q1=A, Q2=B) - **Retention journal** `retained_identities` (append-only): every credential detachment — **unlink, email change, delete** — writes a row (`reason`) before the live identity is removed, so the dossier keeps the full timeline while the `(kind, external_id)` frees for a new account to reuse. No soft-delete → no auth footgun. - **Deletion** (`internal/accountdelete.AnonymizeAndTombstone`): journal + remove the credentials → tombstone (`accounts.deleted_at`) → scrub live `display_name` → **`[Deleted]`** (unspoofable — the editable-name rule forbids brackets) with the real name kept in `deleted_display_name` → anonymise seat snapshots → drop the account's own friendships/blocks/invitations/friend-codes/drafts/pending-codes. **Chat, feedback and complaints are kept** (the tombstone keeps their no-cascade FKs valid). - **Orchestration**: resign active games (opponents not stranded) → **drop all-robot games** (no human opponent; children cascade) → tombstone → revoke sessions. - **Step-up**: a mailed code (`purpose=delete`, no deeplink — a stray click must not delete) for an email account; else a typed **DELETE** phrase (anti-impulse). - **Last login**: `accounts.last_login_at`/`last_login_ip` stamped on the cold-load profile fetch, throttled to once an hour. - **TTL**: a **two-year** reaper (from the event) purges the whole dossier — the journal, and a deleted account's feedback thread + dossier PII; chat and the tombstone stay. ### Surface - Migration 00007 (additive: `retained_identities` + 4 nullable `accounts` columns) — jet regen (accounts + retained_identities only). **No contour wipe** (expand-contract). - Endpoints `/user/delete/{request,confirm}`; gateway `account.delete.{request,confirm}` ops + fbs `AccountDeleteConfirm`/`AccountDeleteRequestResult`; branded ru/en delete email. - UI: Profile delete control + step-up dialog; terminal `AccountDeleted` screen ("Учётная запись удалена") + Close (telegramClose/vkClose; web = none); client/transport/ mock/codec; ru/en i18n. - Admin: user-detail **Deletion & retention** panel (last login, tombstone, the credential journal) + an operator **Delete-user** action (same orchestration). ### Tests - Integration: retention journal (unlink/change/delete), last-login throttle, deletion core (journal + tombstone + credential reuse), all-robot drop vs human keep, step-up (code + no-email), dossier readers, TTL reaper boundary + feedback/PII purge. - Codec wire (delete confirm + request-result); Chromium/WebKit e2e (delete → terminal). Locally verified: Go build/vet/gofmt/unit, gateway, full integration package, codegen idempotent, UI check/test/build, e2e (social/smoke/onboarding). PR4 (Grafana + admin alerts) follows.
developer added 10 commits 2026-07-03 11:25:38 +00:00
Additive/expand-contract: new append-only retained_identities table (the legal
dossier of every detached credential, keyed by account_id, TTL'd by detached_at)
plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and
deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the
go-jet models for accounts + retained_identities only.
On unlink (RemoveIdentity, reason=unlink) and email change (replaceEmailIdentity,
reason=change) write the outgoing credential to retained_identities before removing
the live identities row — so the legal dossier survives while the (kind, external_id)
frees for reuse. Same transaction, so the dossier and live state cannot diverge.
Integration tests cover both reasons.
The profile GET (fetched once per cold app-load by the SPA) stamps accounts
.last_login_at/.last_login_ip, throttled to at most once per hour per account
(best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For.
Feeds the account-deletion dossier. Integration test covers the throttle.
New accountdelete package: AnonymizeAndTombstone journals every credential to the
retention log (reason=delete) then removes them (freeing email/vk/tg for reuse),
snapshots the real display name into deleted_display_name and scrubs the live one to
'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and
drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one
transaction. Chat, feedback and complaints are kept (the tombstone keeps their
no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up.
Integration test covers journalling, tombstone/scrub and credential reuse.
Daily background reaper purges the deletion dossier past its two-year TTL: every
retained_identities row by detached_at (covering unlink/change on live accounts too),
and — for accounts tombstoned before the cutoff — the retained feedback thread plus the
dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact)
and the tombstone row stays. Started from main next to the guest reaper. Integration
test covers the cutoff boundary and the deleted-account feedback/PII purge.
Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or
auto-match-robot; children cascade), keeping games with any human seat (anonymized
instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids
brackets, so no live player can impersonate a deleted account. Integration test covers
drop-vs-keep.
Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
Profile gains a Delete-account control (durable accounts) opening a step-up dialog: a
mailed code for an email account, or the typed DELETE phrase for a platform-only one.
On success the app swaps to a terminal AccountDeleted screen ('Учётная запись удалена')
with a Close that closes the host Mini App (telegramClose / vkClose; web = no close).
Wires deleteRequest/deleteConfirm through client/transport/mock/codec; ru/en i18n;
codec wire test + Chromium/WebKit e2e.
The user-detail console gains a Deletion & retention panel: last login (time + IP),
the tombstone (deleted-at + retained real name), and the retention journal (the legal
dossier of detached credentials). A Delete-user action runs the same deletion
orchestration as the in-app flow (mirrors the email-erase pattern). Store readers
RetainedIdentities + DeletionInfo back the view; integration test covers them.
docs: account deletion = legal retention, not erasure
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
c2a8426b74
FUNCTIONAL (+ru): the deletion user story — anonymised live surfaces ([Deleted]),
freed sign-in methods, a two-year-retained dossier, the code/phrase step-up, game
forfeit + all-robot drop, fresh account on reopen. ARCHITECTURE: the retention model
(retained_identities journal on every detach, tombstone + [Deleted] sentinel, drop
all-robot games, purpose=delete step-up, cold-load last-login, two-year TTL reaper).
developer added 1 commit 2026-07-03 11:59:44 +00:00
fix(delete): review follow-ups — admin Deleted filter, guest gate, dialog spacing
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
3faca690dd
- Admin /users gains a Deleted scope (between People and Robots); every other scope now
  hides tombstoned accounts (deleted_at filter in UserFilter).
- Admin delete-user is offered for any non-deleted account (drop the not-guest gate, so a
  stale is_guest account can still be tombstoned).
- Harden EmailService.ConfirmCode to ClearGuest — defence-in-depth so no confirmed-email
  path leaves is_guest set (the currently-live paths already do).
- Space the delete/change dialog's action row from its input field.
Integration tests: the Deleted filter scoping + ConfirmCode guest promotion.
developer added 1 commit 2026-07-03 12:15:04 +00:00
fix(admin): unified user search across live + deleted, incl. the retention journal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
4cc37e5760
The email/name/external-id search was scoped to one tab and only looked in the live
identities table, so a deleted account (whose credentials moved to retained_identities on
deletion) could not be found by the email/id it held. Now a people search spans live and
deleted accounts in one query (robots only on the Robots tab) and also matches the
retention journal and the retained real name; results carry a 'deleted' badge. Integration
test: a deleted account is found by its held email and external id.
owner approved these changes 2026-07-03 12:16:25 +00:00
Dismissed
owner approved these changes 2026-07-03 12:18:42 +00:00
developer merged commit f1a12c2f44 into development 2026-07-03 12:19:13 +00:00
developer deleted branch feature/email-relay-pr3 2026-07-03 12:19:13 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#164