Files
docs/website/ACCOUNT_DELETION.md
wtclaude 65a5240bd0 docs(account): W1-W3 from the deletion walk are fixed; correct rust PLAN 29.8
ACCOUNT_DELETION.md step 6: W1 (Module-Rust#71), W2 (Module-uo#48) and W3
(Rust-Plugins#47) move from "not fixed" to fixed. modules/rust/PLAN.md 29.8:
Carbon 2.0.262 no longer answers "user not found" for a never-seen Steam id;
it writes the grant or membership into one shared blank record held by every
never-seen id. The bridge now checks for a record first on Carbon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 04:24:13 -05:00

32 KiB

Account deletion — a member deletes their own account, on the website and in the app

Status: plan of record, 2026-10-29, with every question answered (AD4-AD13). §6 step 2 (core) built 2026-10-29 and merged as website#232 (7bd93b8), MODULE_API 1.20.0. Step 3 (modules and kit) built 2026-10-30; steps 4-6 are next. Each phase in §6 stops for the org lead before the next one starts.

Why now. Google Play's Data safety form asks whether the app lets people create an account, and an app that does must let them delete it, both in the app and at a web address given to Play (android/PLAN.md M8, step 2). The app starts accounts in two places: the sign-in screen's link to the deployment's sign-up page, and in-app game-account creation where a game server offers it (PlayerShardApi.createAccount). The platform had no self-service deletion: only an admin could delete an account. The org lead chose to build it (AD1) and to do it before report, block and hide (USER_SAFETY.md), so it takes the next MODULE_API minor.

1. What a member can do

Where What they see What happens
Website, Account (PlayerAccount.jsx) a Delete my account section at the bottom: what goes, what stays (§4.1), and a confirm step the account is deleted at once (AD4), the member is signed out everywhere, and the site says so
App, Account (AccountScreen.kt) the same section and the same confirm step the same, and the app clears what it holds on the phone, as a sign-out does
runicgateway.com /delete-account/ how to do it on the website or in the app, what is deleted and what is kept, and who to ask when you cannot sign in nothing; it is the page whose address Play is given (AD2)

The confirm step asks for the account's password, and for a two-factor code when the account has two-factor sign-in on. It also asks the member to type their username, so a mis-tap on a phone cannot delete an account. Signing in with Google, Discord or Steam is a convenience on top of an account that already has its own password (AD7), so the password is always the proof.

Every member can delete themself, staff included. The one refusal is the last admin, as the admin route already refuses. Then a deployment would be left with no one who can run it.

2. Decisions (org lead, 2026-10-29)

  • AD1 — Build self-service deletion. The org lead chose this over an explanation page alone, and over removing the app's account-creation links. A member deletes their account on the website and in the app, and that removes the account and its game links.
  • AD2 — runicgateway.com hosts the deletion page Play is given. We publish the app, so Play needs one address from us. Every deployment runs its own site, so that page explains the steps rather than performing them.
  • AD3 — Before report, block and hide. It takes MODULE_API 1.20.0, and USER_SAFETY.md's reportTargets moves to 1.21.0.

The org lead's answers to the six questions in §5, the same day:

  • AD4 — Delete now (Q1). There is no grace period. The typed username and the password are the safeguard.
  • AD5 — What they wrote stays, attributed to "[deleted player]" (Q2). For members, every username snapshot of a deleted account reads "[deleted player]": their threads, posts and uploads, and the records in gap 2. That label replaces today's "[deleted account]".
  • AD6 — Staff keep the name and the IP (Q3). The snapshots and the activity log's IP addresses stay in the database, and the staff views keep showing them, marked as a deleted account, for moderation. So AD5 is a rule about what members see, not a change to the stored rows.
  • AD7 — The password is the proof (Q4). SSO is a convenience. A member must already have a proper account before they can link a sign-in provider, so every account has its own password, and the confirm step always asks for it. An account that somehow has no password is told to set one first, on the same Account page.
  • AD8 — A UO game account stays on the shard (Q5). Only the link goes. The shard's staff handle the shard account like any other. There is no protocol change.
  • AD9 — A deleted staff account's username is never available again (Q6). It can never be registered, or taken by a username change. A player's username is free at once.

Asked after the plan was first written ("will deletion call the unlink function for that player?"), the same day:

  • AD10 — Deletion unlinks through each module's own unlink, before the row goes. A database cascade is not an unlink. For UO, the link lives on the shard (gap 8), so dropping the site's mirror would leave the shard attributing the game account to a user who no longer exists. The hook is therefore onUserDeleting and runs before the delete. Each module calls the unlink it already has, for every link the person holds, so the game sees exactly what a player's own unlink produces.
  • AD11 — An unlink that cannot finish never holds up the deletion.
    • A shard that is offline (503) gets a pending unlink in the module, retried when the bridge reconnects.
    • A protected staff account, which the shard refuses to unlink (403), is logged and shown to admins as still linked on the shard, for the shard's staff to clear by hand.

Asked while building core (§6 step 2), the same day:

  • AD12 — A wrong password or code is 400, not 401. §4.1 first said 401. In the app, a 401 on a signed-in call goes to TokenAuthenticator, which spends the refresh token and replays the request with the same body: one typo would make two password attempts, both counted by the login backoff, and a failed refresh would sign the member out mid-deletion. Every other self-service password check already answers 400, so deletion does too, with code: 'wrong_password' or 'wrong_code'.
  • AD13 — A deleted account drops off a Team's Forum guests list. Its grant row stays (the id is set NULL) and the staff grant ledger still shows it with the name, marked deleted. A grant is matched by user id, so a deleted account cannot use it and is not a guest.

3. What exists today (read 2026-10-29, website main 222bcb0)

  • The admin route. DELETE /api/v1/admin/users/:id refuses your own account and the last admin, then users.remove(id), a plain DELETE. Everything else is the schema's foreign keys.
  • What the foreign keys already do. Of the 59 keys that reference users(id):
    • CASCADE removes what belongs to the account alone: linked sign-in identities, mobile refresh tokens and SSO codes, revoked sessions, trusted devices, recovery codes, password resets, email verifications, push devices, notification subscriptions and channel prefs, in-app notifications, and the engagement outbox, cooldowns and digest state.
    • SET NULL detaches what the member made from them: news posts, wiki edits, forum threads, posts and uploads, reports filed, appeals, Team activity, event participation, engagement send logs, and the activity log.
    • Modules: Module-uo's shard_account_links, and Module-Rust's rust_account_links, rust_perm_group_members, rust_perm_grants, rust_perm_run_grants and rust_permgroup_members, all CASCADE. Module-Rust's grants and group memberships keyed straight to a Steam id (rust_perm_steam_grants, rust_permgroup_steam_members) have no site account behind them, and stay. Staff gave them to a player in the game, not to a member of the site. A cascade is not an unlink, though (gap 8, AD10).
  • Sessions die at once. requireAuth re-reads the user on every request and answers 401 when the row is gone (session.middleware.js). So a deleted member's web cookie or mobile access token stops working on its next use, without waiting for it to expire.
  • Rust permissions are withdrawn in the game by themselves. permSync.js compares a digest of the desired set with what each server last confirmed. When the grant rows cascade away, the digest changes, and the next tick pushes the set with the withdrawn grants.
  • Teams follow on the next reconcile. team_members.user_id is SET NULL. The roster belongs to the module and re-syncs every 15 minutes (TEAMS.md).

The gaps:

  1. There is no self-service route, and no screen on the web or in the app.
  2. Their username survives in snapshot columns. The Teams tables store a username next to the user id, deliberately, so a record still names someone after the account is gone. teamForum.model.js shows "[deleted account]" only when the snapshot is empty too. There are two kinds:
    • What they wrote: team_forum_threads.created_username, team_forum_posts.author_username, team_forum_uploads.uploader_username.
    • Records of what was done:
      • content_reports.reporter_username and handled_username
      • team_forum_moderation.actor_username
      • team_forum_grants.username, granted_username and revoked_username
      • team_leader_overrides.actor_username
      • team_moderation_requests.requested_username and decided_username
  3. The activity log keeps IP addresses. Rows for the deleted member keep ip, and detail can carry a username, with only user_id cleared. They stay, for staff (AD6).
  4. A module cannot learn that a user was deleted. If a module keeps user data without a foreign key, or has a side effect in the game, it has no hook. The Module API has onIdentityLinked, but nothing for deletion.
  5. A game account the app created stays on the game server. For UO the link cascades, but the shard account and its characters remain, which is intended (AD8).
  6. The demo's four accounts (demo/mode.js) must not be deletable.
  7. Nothing stops a deleted name being reused. Registration and username changes only check the live users table (AD9).
  8. A cascade skips the modules' unlink, and for UO that leaves the shard linked.
    • UO: shard_account_links is only a mirror. The shard keeps the site user id on the game account (INTEGRATION.md: "permanently tagged with your websiteUserId"), and every mob.login reports it as webId. That is why the staff unlink in usersShard.controller.js calls the sidecar's DELETE /link/{account} first and drops the mirror after. A cascade alone would leave the shard, and the sidecar's own link mirror, attributing the account to a deleted user forever, with every login still carrying that webId.
    • Rust: the site's rust_account_links is the only record; the plugin keeps no link state. The cascade gets the same result as links.unlinkOwned, apart from its rust.account.unlink log entry and its prompt Teams re-sync. Calling the real function keeps both.

4. Design

4.1 Core (website)

  • One service, two routes. A new accountDeletion.deleteAccount(userId, { req, by }) holds the whole procedure. The admin route's users.remove(id) call is replaced by it, so a deletion by staff and a deletion by the member do the same thing.
  • The member's route. DELETE /api/v1/auth/me/account, behind requireAuth and accountChangeLimiter.
    • Body: { username, password, code?, recoveryCode? }.
    • The typed username must match the account, ignoring case.
    • The password is always checked (AD7).
    • A two-factor code or a recovery code is checked when two-factor sign-in is on.
    • Refusals, each with a code:
      • 400 username_mismatch for a username that doesn't match
      • 400 wrong_password, code_required or wrong_code (AD12: 400, never 401)
      • 409 password_required for an account with no password: the screen points at the Account page's set-a-password section, which already exists for that case
      • 409 last_admin for the last admin
      • the demo's own refusal for a demo account (demo/mode.js, the same lock other breaking settings use)
    • #swagger.* annotations; the spec is regenerated.
  • The procedure, in order:
    1. Before the delete, read what is needed afterwards: the email address (for the notice below), the username and the role. Then run every module's onUserDeleting hook (below), in turn, each in its own try. That is where game links are unlinked (AD10).
    2. In one transaction:
      • when the role is staff (anything but player), add the username to a new reserved_usernames table (AD9)
      • delete the users row, so the foreign keys do the rest. The snapshot columns, the content and the activity log are not touched (AD5, AD6).
    3. After commit, fire-and-forget, each in its own try:
      • teams.reconcile({ reason: 'account deleted' })
      • an activity-log entry, user.delete (by staff) or user.self_delete, naming the id and never the username
      • an email to the address read in step 1, when there was one and mail is configured: "your account on was deleted on ". This is the only notice a person gets if someone else used their unlocked phone, and it says who to contact.
    • Nothing can stop a deletion once it starts. A module hook that throws, or that takes too long, is logged, and the deletion goes ahead, because Play requires the request to be honoured. Core gives each hook a time limit (10 seconds) so a hung game server cannot hold a member's request open; whatever the module had not finished is its pending work (AD11).
  • Who sees the name (AD5, AD6). A snapshot whose id column is now NULL belongs to a deleted account; nothing else clears those ids. Rendering follows the viewer:
    • Members, Team leaders included, and signed-out visitors see [deleted player]. That covers forum threads, posts and uploads, and any record a member can read. The constant in teamForum.model.js and contentReports.model.js changes from [deleted account], and the "id NULL" case now wins over a present snapshot.
    • Staff (admin, editor, moderator) see the stored username with a deleted account marker, in the Reports queue, the forum's moderation views, the user and activity screens, and Team moderation requests. The activity log keeps its IP addresses.
    • The rule lives in one helper, displayAuthor(row, viewer), which every model that renders a snapshot uses, and its tests cover each table in gap 2.
  • reserved_usernames (AD9) holds username_norm (the primary key, lowercased), reason (deleted_staff) and reserved_at. Registration, an invite's acceptance, an admin creating an account, and a username change all refuse a reserved name, with the same message as a taken one. There is no release path, because the org lead said "completely unavailable". A player's username is not added and is free at once.
  • MODULE_API 1.20.0: api.onUserDeleting(async ({ userId, deletedBy }) => {}), where deletedBy is 'self' | 'staff'.
    • It runs before the row goes, while the module can still read the person's links (AD10). Its job is to undo in the game, and anywhere else the module holds state, what the site's rows are about to stop saying: unlink each game link through the module's own unlink, and clean up data kept without a foreign key.
    • It cannot veto. Throwing, or running past core's time limit, is logged, and the deletion goes ahead (AD11). A module that cannot finish keeps its own record of what is left, and retries.
    • It is the same staging and validation as onIdentityLinking.
    • MODULE_API.md §2.4 and §2.5 are amended. The Integration Kit's coreApi check goes red until it is re-pinned, which is how the kit is meant to notice.
  • What goes and what stays is written once in BACKEND_DESIGN.md, and the screens and the runicgateway.com page say the same in plain words:
    • Goes: the account, password, email address, two-factor secret and recovery codes; every session, trusted device and push registration; linked Google, Discord, OIDC and Steam sign-ins; game-account links and, in Rust, the permissions and groups the site gave those Steam ids; notification settings and the inbox.
    • Stays, shown to other members as "[deleted player]": what you wrote where others read it (AD5), and reports you filed and appeals you made, as moderation records.
    • Stays, and staff can still see who it was: the same records with your username, and the activity log with its IP addresses, for moderation (AD6). Kept until the operator removes them.
    • Stays, detached: event results (the score and the in-game name, no site account), the delivery log's hashes and the suppression list (collection.mjs already says so).
    • If you were staff: your username can never be used again (AD9).
  • Docs: BACKEND_DESIGN.md (the route, the procedure, reserved_usernames, the display rule, the list above), TEAMS.md (the forum's deleted-author rule), MODULE_API.md, OpenAPI.

4.2 Website client

  • PlayerAccount.jsx gets a Delete my account section, last on the page and visually set apart. It holds the "what goes, what stays" text and a button that opens a confirm dialog with the username, password and (if needed) code fields. Delete stays disabled until the username matches.
  • On success, the client clears its session state and lands on the home page with a notice: "Your account has been deleted."
  • On the demo the section shows, and its button is disabled with the demo's usual locked wording, so a reviewer can see the feature exists.

4.3 Android app (edge)

  • AccountScreen.kt gets a Delete my account row that opens a new screen with the same text and fields, and MeApi.deleteAccount(body).
  • On success the app does what a sign-out does, without the logout call, because the server has already dropped everything. It clears the tokens, the trusted-device token, the push registration and the inbox snapshot, then returns to sign-in with "Your account has been deleted."
  • On the demo the server refuses, and demoLockInterceptor shows the usual locked message. The row is still visible, which is what Play's reviewer needs to find.

4.4 Modules

  • Module-Rust (edge):
    • onUserDeleting calls links.unlinkOwned(steamId, userId) for each Steam id the person holds, the same call the player's own unlink makes. That keeps its rust.account.unlink entry (with detail.reason: 'account deleted') and its Teams re-sync.
    • It then marks the permission sync dirty, so withdrawn grants reach the game on the next tick rather than waiting for the digest check.
    • It never fails on the game: the link is the site's own record and the plugin keeps none, so there is nothing pending.
    • Core-ref pin.
  • Module-uo:
    • onUserDeleting calls the staff unlink's path for each game account in shard_account_links: uoLinkClient.unlinkAccount({ actor, account }), where actor is web:account-deletion, then shardLinks.removeByAccount(account) on success. That clears the shard's WebsiteUserId tag, so attribution stops at once. The shard account and its characters stay (AD8).
    • Shard offline (503, or no sidecar configured): the account goes into a new shard_pending_unlinks table (account, queued_at, attempts, last_error), and the mirror row goes with the cascade. The module retries the queue at boot and whenever the bridge reconnects, until the shard answers 200 or 404 (404 means the shard has nothing to clear).
    • Protected staff account (403): the shard refuses by design, so retrying will not help. The account goes into the same table marked refused. Admin → Shard shows it as still linked on the shard until shard staff clear it by hand, and the module drops the row once the shard stops reporting that webId.
    • Core-ref pin.
  • Integration Kit: the re-pin, and a paragraph in the module chapter. onUserDeleting is where a module unlinks anything a game holds for the person, through its own unlink, and cleans up data kept with no foreign key. It can't stop the deletion, so a module that talks to a game needs a retry for when the game is away.

4.5 runicgateway.com

  • /delete-account/, the page Play is given (AD2). It covers:
    • the app's name and ours
    • the steps on the website and in the app
    • what is deleted and what is kept, and for how long: the moderation records that stay, and that other members see "[deleted player]"
    • that we hold no account, because each community runs its own site
    • what to do if you cannot sign in: ask that community's operator, who can delete the account from the admin panel
  • The data, written once. The page's facts become deployment rows in collection.mjs, so /privacy and the page agree. PLAY_DATA_SAFETY.md's account-deletion bullet then names the URL and answers "yes" to "can users request deletion".

5. The questions, as they were asked and answered (org lead, 2026-10-29)

Asked, with the example given Answer
Q1 Delete now, or lock for 14 days and let a sign-in cancel it? (a player taps Delete at 2 a.m. after a bad raid) Delete now (AD4)
Q2 Keep what they wrote, anonymously, or delete it? (a forum guide with 40 replies) Keep it; every username snapshot reads "[deleted player]" (AD5)
Q3 Keep or clear the activity log's IPs and the moderation snapshots? (a banned player returns under a new name) Keep the name and the IP on the admin side, for moderation (AD6)
Q4 How does an account with no password confirm? (a Discord-only player) Every account already has a password; SSO is a convenience on top of a proper account (AD7)
Q5 What happens to a UO game account the app created? (Thorne, with three characters) Leave it on the shard (AD8)
Q6 Can a deleted username be taken at once? (a moderator Ravenna impersonated the next day) Staff names are completely unavailable (AD9)

6. Build order

  1. This plan (docs), with the answers (AD4-AD9).

  2. Core (website): §4.1 and §4.2, MODULE_API 1.20.0, tests (the service's procedure, the route's refusals, the demo lock, a module hook that throws, displayAuthor per table, a reserved staff name), OpenAPI, BACKEND_DESIGN.md, TEAMS.md, MODULE_API.md. One PR. Built 2026-10-29. Beyond the list: the section is on the staff Account page (AccountAdmin.jsx) as well as the player one, since staff delete themselves too; the staff views (Reports queue, Teams forum log, moderation requests, leader overrides, the forum itself) mark a deleted account's stored name "(deleted account)"; a transaction() helper in utils/db.js. Walked locally against a throwaway database: refusals, deletion on the web and over a bearer with 2FA, the other device's session dying on its next request, staff names refused at admin create and at a rename, a player name free at once, the admin route, the last admin refused, and the web screens for a player and an admin. The forum's "[deleted player]" rendering and the demo lock are covered by tests only; the walk in step 6 sees them with a game module and the demo flag on.

  3. Module-Rust (edge) and Module-uo: §4.4's hooks plus their core-ref pins, one PR each. Module-uo's PR adds shard_pending_unlinks, the retry, and the still linked on the shard list. Integration Kit: the re-pin and the paragraph, one PR. Built 2026-10-30, all three pinned to 7bd93b8 with coreApi ^1.20.0:

    • Module-Rust (edge): links.unlinkForDeletion, as §4.4 says.

    • Module-uo: utils/accountDeletion.js. Beyond §4.4:

      • every account is queued before the first sidecar call, because one call can outlast the hook's 10 s;
      • the retry also runs on every live server.hello (the plugin reconnecting to a sidecar that stayed up);
      • a pending row clears on a live login without the webId, as a refused one does;
      • still linked on the shard is pendingUnlinks on GET /admin/uo-link/config and a section on Admin → Shard, so there is no new route.

      The table is in modules/uo/SCHEMA.md.

    • Integration Kit: chapter 2 also gained the demo's two unlisted refusals (multipart and data: URIs), which it had never taught.

    • Checked: each repo's tests, and both modules' frozen-manifest job reproduced against 7bd93b8.

    • Not walked: the live deletions are step 6.

  4. Android (edge): §4.3. One PR. Built 2026-10-30. Delete my account is the last thing on My account, under a red rule, and opens its own screen with the website's words. Beyond §4.3:

    • The refusals are read from the body. The call reads the raw response, as the trusted-device cap does, so each code becomes its own sentence (the web's wording).
    • The trust token goes too. A sign-out keeps it for the next sign-in. A deleted account has no next sign-in, so the token is dropped.
    • The notice is the shell's snackbar, the one the demo's refusals use. The app opens sign-in with Home beneath it.
    • Not touched: the opt-in to push, which belongs to the phone, not the account. It survives, as it does a sign-out.

    Checked on the s22_ultra emulator against a local core at 7bd93b8, with two throwaway players. A wrong password showed "Your password is incorrect." The server logged one 400 and no replayed call, so AD12 holds in the app. Each player was deleted, one with an authenticator code. The app opened sign-in with the notice, and back led to a signed-out Home. The demo refusal and the no-password and last-admin refusals are covered by tests only; step 6 walks them.

  5. runicgateway.com: §4.5, plus PLAY_DATA_SAFETY.md regenerated. One PR. Built 2026-10-30. The facts are the deploy-account-deletion row in collection.mjs, in the website's words, with how long each kept thing is kept, which Play asks and the screens don't say. The row also lists the delivery log's hashes and the suppression list, which §4.1's list has and the screens leave out. The app rows that a deletion touches say so, and the Data Safety notes answer "yes" and name the URL. Two answers from the org lead (runicgateway.com PLAN.md):

    • D85: the page is linked from the footer's legal bar, /privacy and /app/.
    • D86: someone who cannot sign in is told to try "Forgot your password?" first, then their community's operator. They are never pointed at us, and the page says we cannot do it.
  6. A walk, using a throwaway account on each surface:

    • delete on the website, and in the app on the emulator
    • the other device's session dies on its next request
    • a Rust-linked account's grants leave the rig on the next sync, with a rust.account.unlink entry
    • a UO-linked account (the local ServUO with the uo-link sidecar): the shard's link is cleared, and the next login carries no webId. With the sidecar stopped, the unlink waits in shard_pending_unlinks and clears when the sidecar comes back
    • a forum post shows "[deleted player]" to a member and the old username, marked deleted, to staff
    • the deleted staff username is refused at registration and at a username change
    • the demo refuses
    • the last admin is refused

    Walked 2026-10-30, every row passing. Two local cores at website 7bd93b8, one module each, on throwaway databases: Module-Rust edge e30c7d5 linked to the Carbon rig (Rust 26139947, Carbon 2.0.262, both the latest), and Module-uo main 148cf0f with the local ServUO on servuo-plugins main and a uo-link sidecar built from link main (protocol 8). The app was the step 4 build on the s22_ultra emulator.

    • Website, UO-linked. A player linked to seed_000 through the real [link code, with a second session open on a phone token, deleted the account from /account. The site showed "Your account has been deleted.", and the phone token answered 401 on its next request. The shard's WebsiteUserId tag was gone, the log held shard.account.unlink with reason: account deleted, and Admin → Shard listed nothing still linked. A game login over a real socket before the deletion sent mob.login with webId: "2". The same login after it sent no webId.
    • Sidecar stopped. A second linked player deleted their account while the sidecar was down. The deletion answered 200 at once, seed_002 was queued as pending with the error fetch failed, and Admin → Shard listed it under Still linked on the shard. The shard still held the tag. When the sidecar started again, the ingest socket connected and the queued unlink finished within a second. The row cleared and the tag was gone.
    • Forum. The deleted player's two threads in their guild's Team forum read "[deleted player]" to a member, on the thread and on the post, in the API and on the guild page. Staff saw "uwplayer1 (deleted account)" there, with the moderation controls.
    • App, Rust-linked. A player linked to a stand-in Steam id held autodoors.use, granted from the site, and the app's My Rust account showed it as "Carbon rig · has it". They deleted the account in the app while a web session stayed open. The app opened sign-in, and the web cookie answered 401 on its next request. The log held rust.account.unlink. The next permission sync, 29 s later, pushed one revoke, and c.show user on the rig listed no permissions.
    • Staff name. A moderator deleted their own account. RwMod was then refused at registration ("That username is already taken.") and as rwmember's new username on /account. An unused name registered.
    • Demo. With DEMO_MODE=true, demo-player saw "This is turned off on the demo site." on /account, and an admin's DELETE /admin/users/:id on that account answered 403 DEMO_LOCKED. An ordinary account on the same demo deleted itself.
    • Last admin. The UO site's only admin was refused on /admin/account: "You are the only admin. Make someone else an admin before you delete your account."

    The walk used stand-ins in two places. The Steam link was written straight into rust_account_links, because the rig had no second player to type /link. The [link codes and the game logins came from a probe and a small socket client kept outside the repos. The deletion paths themselves were the real ones.

    Found, then fixed (org lead, 2026-10-30):

    • W1 (Module-Rust). edge held an empty file named Promise.reject(dup), committed by step 3's PR (#70). It is a stray from a tool, not code. Removed in Module-Rust#71.
    • W2 (Module-uo). Still linked on the shard said "tried 1 time, and tried again when the bridge reconnects" about a retry that has not happened yet. It now says "It tries again when the bridge reconnects." Module-uo#48.
    • W3 (Rust bridge, not deletion). On Carbon, a group membership for a Steam id the server had never seen was reported as applied, but c.show group listed no users. The cause was worse than the symptom: Carbon writes such a grant or membership into one shared blank record, which every never-seen id then holds until a restart (rust/PLAN.md §29.8). The org lead chose to hold these until the player joins. Rust-Plugins#47 checks for a record on Carbon before writing, and reports notLanded or pending instead. It was proven on the rig before and after the change.
    • Observed, unchanged: user.self_delete names the id only, as §4.1 says, so the activity log alone cannot tell staff which account deleted itself. The auth slow-down covers the delete route, so a third call from one address took about 4 s.
  7. Then USER_SAFETY.md (report, block and hide, now 1.21.0), then android/PLAN.md M8's cutover.