asobi_player_erase (asobi v0.84.0)

View Source

Delete 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:

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_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.

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

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 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 to auth_cache_ttl_ms against a row that no longer exists, which is the revocation SLA that module's moduledoc already states. The live session, killed through asobi_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 is asobi_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.

core_relations()

-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.

orphan_blocker/1

-spec orphan_blocker(term()) -> {orphaned_extension_rows, binary()} | not_orphaned.

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.

post_erase_hooks()

-spec post_erase_hooks() -> [{module(), atom()}].

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.

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.