asobi_player_erase (asobi v0.84.0)
View SourceDelete one player and everything core holds about them, in one transaction.
This is the single place core deletes a player. steps/1 is the delete
sequence; three callers reach it, each wrapping its own transaction:
run/1,2here - the shell and the operator route (POST /api/v1/ops/players/:id/erase);asobi_guest_reaper- the retention sweep;asobi_player_controller:erase_self/1- the data subject's ownPOST /api/v1/players/me/erase.
All three route a rolled-back erasure through orphan_blocker/1 so a removed
extension's rows are named rather than surfaced as a bare constraint error.
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_transactionis not this table: it is soft-currency bookkeeping keyed onwallets, 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 (seeasobi_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 whatasobi_extension:erase_player/1is for.
Rows a removed extension leaves behind
Core clears its own children and each installed extension's erase_player/1
clears the extension's. A package that has been removed runs neither: its
tables and rows survive the uninstall, its foreign key into players.id is
still no_action, and the parent delete this function ends on raises against
them. Rather than surface that as a bare {badmatch, {pgsql_error, ...}}, the
delete sequence is wrapped so a foreign_key_violation (SQLSTATE 23503)
becomes {error, {orphaned_extension_rows, Table}} - Table is the referencing
table the absent package owns, read from the Postgres error. The player is not
erased, the transaction rolls back cleanly, and the operator learns which
package to reinstall or purge instead of reading a constraint name off a
stacktrace. guides/extensions.md documents the rule where it bites.
The translation is narrow: only a 23503 naming a table outside
core_relations/0 - the set steps/1 itself sweeps, plus players and the
ops audit table - reads as extension residue. A 23503 naming a core-swept
table, or one carrying no table name, is a core bug or a write-race, not a
removed package; those keep the raw reason and surface as
ops.erase_failed (500), which is the escalate signal, never the benign
"reinstall the package" 409.
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.
Those post-commit surfaces split two ways. Revoking the auth cache and killing
the session are core's own session/identity teardown for the player it just
deleted - kernel work erase owns and will always own. Taking the player off a
running leaderboard is a subsystem cleaning up its own in-memory state, and
core should not name a subsystem's internals at its own call site. So the
leaderboard eviction runs through a registry, post_erase_hooks/0, one
{Module, Function} per subsystem that needs post-commit cleanup, invoked
best-effort in list order. When leaderboards extracts (Wave 2)
post_erase_hooks/0 is reimplemented to walk the installed subsystems - the
same registry ratchet 3 introduces - rather than return this literal, so the
leaderboard entry is no longer named here; nothing re-registers itself into a
list. The registry stays core-internal until then, deliberately off the public
asobi_extension behaviour, which is a separate contract decision.
Summary
Functions
The in-memory surfaces the transaction cannot cover, run after it commits.
The Postgres relations steps/1 clears, by their real table names.
Classify an exception raised inside the erase transaction.
The subsystem cleanups to run after a player-erase commits, in order.
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
-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 and asobi_player_controller:erase_self/1
- calls it once that transaction has committed.
Two kinds of work run here, and the split is deliberate:
- Kernel teardown, run directly.
asobi_auth_cache- without it a deleted player's access token keeps resolving for up toauth_cache_ttl_msagainst a row that no longer exists, which is the revocation SLA that module's moduledoc already states. The live session, killed throughasobi_presence. Both are erase's own session/identity teardown for the player it just deleted; neither is a subsystem's concern and neither will ever extract, so core names them here rather than on the registry. - Subsystem cleanup, run through
post_erase_hooks/0. Today that isasobi_leaderboard_server: each board keeps its entries in ETS and reads from there, hydrating from Postgres only at init, so 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. It is a subsystem cleaning up its own state, so it is a registered hook, not a call core spells out here.
Every step is idempotent, so a retried erasure is safe, and every step is best-effort in isolation: one that raises is logged, naming the step, and swallowed, so a wedged subsystem cannot stop another's cleanup or the kernel teardown, and a committed erasure always stands.
-spec core_relations() -> [binary()].
The Postgres relations steps/1 clears, by their real table names.
The escalation boundary for orphan_blocker/1: a 23503 naming one of these is
a core defect or a write-race, not a removed extension. Derived from the schema
modules steps/1 sweeps (asobi_player:table/0 and friends give the real
relation name, which differs from the module name), plus the ops audit table
asobi_ops_audit:record_strict/4 writes inside the same transaction. players
is already in the swept set. asobi_player_erase_tests fails if steps/1 gains
a schema whose table is not covered here.
Classify an exception raised inside the erase transaction.
{orphaned_extension_rows, Table} only when the failure was a Postgres
foreign_key_violation (SQLSTATE 23503) naming a table outside
core_relations/0. Core clears every child it owns before deleting players,
so a foreign key that still refuses the parent delete and names a table core
does not sweep belongs to a removed extension. A 23503 naming a core-swept
table (a bug or a write-race), or one carrying no table name, is not_orphaned
and keeps its raw reason so it escalates rather than reads as a benign removed
package. Every non-23503 failure is not_orphaned too. Exported for the other
callers of the delete sequence - asobi_guest_reaper and
asobi_player_controller:erase_self/1 - which wrap their own transaction.
The subsystem cleanups to run after a player-erase commits, in order.
The seam. Each entry is one subsystem's post-commit cleanup of the player just
erased, invoked as Module:Function(PlayerId) by after_commit/1. It exists so
core does not name a subsystem's internals mid-after_commit/1: the one edge
that used to reach straight into asobi_leaderboard_server:evict_player/1 is
now this single list entry. When leaderboards extracts (Wave 2) this function is
reimplemented to walk the installed subsystems - the same registry ratchet 3
introduces - rather than return a literal, so the leaderboard entry is no longer
named here. There is no extension-facing hook-registration API today and this is
not one; the registry is kept core-internal for now, off the public
asobi_extension behaviour, which is a separate contract decision.
Ordered and deterministic: hooks run top to bottom, after the kernel teardown.
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.
-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.
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.