asobi_player_erase (asobi v0.72.5)

View Source

Delete one player and everything core holds about them, in one transaction.

This is the single place core deletes a player. asobi_guest_reaper is one caller of it, not the site itself, and the operator-facing route (POST /api/v1/ops/players/:id/erase) is another.

Why the child list is written out here

All 15 core foreign keys into players.id are ON DELETE NO ACTION, so the player row cannot go until every child has. A blanket ON DELETE CASCADE migration would be shorter and is deliberately refused: a database cascade fires below this function's control flow, so asobi_extension_erase would never run, and iap_transactions - real-money receipts - would be destroyed silently. Enumerating the children in code is what makes the policy readable, testable and auditable. It is also exactly what guides/extensions.md tells extension authors to do, so core doing otherwise would be telling them one thing and doing another.

Delete, or sever

Two core tables are severed rather than deleted, and only two:

  • iap_transactions - a purchase record outlives the account. A refund or chargeback dispute needs the provider transaction id, and statutory retention beats erasure for it. asobi_transaction is not this table: it is soft-currency bookkeeping keyed on wallets, and it goes with the wallet.
  • groups.creator_id - deleting the group to free the key would destroy every other member's data.

Everything else is deleted outright. That is the anonymisation: every player-referencing table stores a bare uuid and nothing else about the person, so once players and player_identities are gone the surviving ids resolve to nobody. A tombstone player row would be a record about a person who asked to be erased, and it would still need a unique username.

Three columns hold ids with no foreign key at all, and each is left alone on that argument rather than by oversight:

  • match_records.players - a jsonb list of ids.
  • votes.votes_cast - a jsonb object keyed by voter id, #{VoterId => OptionId}. Same shape of argument, and it is exported (see asobi_player_export), so it is named here too.
  • zone_snapshots.entities - opaque game-defined state. A game may key entities by player id, so this one is not core's to reason about; a game that puts personal data in it owns erasing it, which is what asobi_extension:erase_player/1 is for.

Atomic, never best-effort

One transaction, every result asserted, so a bare {error, _} becomes a badmatch that raises and rolls the whole thing back. A half-finished erasure that reports success is a worse answer to a deletion request than one that fails loudly and can be retried - the argument asobi_extension:erase_player/1 already makes for extensions, applied to core's own tables.

The audit row commits with the erasure

ADR 0007's rule is that the audit runs after the mutation and never fails it. Erasure is the stated exception: the data is gone by definition, so the audit row is the only surviving evidence the request was honoured. It is written inside the transaction through asobi_ops_audit:record_strict/4, and a failed insert rolls the erasure back. ops_audit_entries carries no foreign key to players, so the row outlives its own target by construction.

An automated retention sweep writes no rows - see asobi_guest_reaper.

What the transaction cannot cover

Evicting the player from asobi_auth_cache and from every live asobi_leaderboard_server, and killing the live session, run after the commit, because an ETS eviction and a process exit cannot roll back. All are idempotent, so a retry is safe. See after_commit/1.

Summary

Functions

The in-memory surfaces the transaction cannot cover, run after it commits.

Erase PlayerId from an Erlang shell, and audit it as the node's own action.

Erase PlayerId on behalf of an ops actor.

The delete sequence, with no transaction of its own.

Functions

after_commit(PlayerId)

-spec after_commit(binary()) -> ok.

The in-memory surfaces the transaction cannot cover, run after it commits.

An ETS eviction and a process exit cannot roll back, so none of these may run before the commit. run/1,2 call this for you; a caller that holds its own transaction - asobi_guest_reaper - calls it once that transaction has committed.

  • asobi_auth_cache - without it a deleted player's access token keeps resolving for up to auth_cache_ttl_ms against a row that no longer exists, which is the revocation SLA that module's moduledoc already states.
  • asobi_leaderboard_server - each board keeps its entries in ETS and reads from there, hydrating from Postgres only at init. Deleting the rows does not take an erased player off a board that is already running, and a score still pending for them re-inserts against a foreign key that is now gone. See asobi_leaderboard_server:evict_player/1.
  • The live session.

All three are idempotent, so a retried erasure is safe.

run(PlayerId)

-spec run(binary()) -> {ok, map()} | {error, term()}.

Erase PlayerId from an Erlang shell, and audit it as the node's own action.

The floor a self-hoster has to be able to stand on: a release, a remote shell, and no console, no operator secret and no cloud. There is no capability check because there is nothing here to check - a caller holding a shell on the node could call asobi_repo:delete_all/1 directly. Capabilities guard the network surface; see run/2.

run/2

-spec run(binary(), asobi_ops_auth:actor()) -> {ok, map()} | {error, forbidden | term()}.

Erase PlayerId on behalf of an ops actor.

Checks the erasure capability class in-function, the way ADR 0007's core-wrapped mutations do, because the class is checked once in asobi_ops_caps whichever entry point the caller reached. A refusal is audited too: a denied attempt to erase somebody is worth a row.

steps(PlayerId)

-spec steps(binary()) -> ok | no_return().

The delete sequence, with no transaction of its own.

For a caller that already holds one - asobi_guest_reaper does. Every result is asserted, so a failure raises inside the caller's transaction and rolls it back. Do not soften that into a case.