Skip to content

Runbook: Player Data Erasure (GDPR Article 17)

Destructive and irreversible. Read this entire runbook before starting. The erasure transaction blocks all writers and cannot be rolled forward — only restored from the Step-4 backup. Do NOT run this from memory at 3am; the procedure assumes you have time to read each step.

Disclaimer: This runbook produces a best-effort erasure based on the PII tables declared by the core engine and by each loaded content pack. EU deployments require legal review of the generated SQL before execution. MAID ships no legal advice; consult your DPA.

Prerequisites

  • Tools: maid-admin, psql (from postgresql-client-16), a text editor on the host. No internet required at the host once the request is verified.
  • Access: root, or an equivalently broad sudo grant — erasure runs sudo -u postgres … and sudo -u maid-engine … service-user shells and sudo systemctl on the instance units, none of which the narrow maid-admin priv-helper allowlist (packaging/sudoers/maid-admin.template) grants (it only covers fixed maid-admin <verb> calls, and no maid-admin gdpr erase-player verb exists yet — see Limitations). There is no maid-ops group in the packaging — only the maid-admin group plus the maid-engine service user (the restore/cleanup units run as root).
  • Source checkout (MAID_SRC): deploy/scripts/backup.sh is repo-only and is not staged under /opt/maid/current until M1.4, so export MAID_SRC=/path/to/maid-checkout before the backup steps below; they invoke "${MAID_SRC}/deploy/scripts/backup.sh" and forward MAID_SRC through sudo with --preserve-env=MAID_SRC. Today you need direct sudo -u postgres psql or equivalent.
  • The same MAID_SRC checkout also provides the maid CLI (there is no system-wide maid until M1.4). Build its venv once — sudo -u maid-engine -H uv sync --frozen --project "${MAID_SRC}" — and the commands here run it as "${MAID_SRC}/.venv/bin/maid".
  • Env file: /etc/maid/<instance>.env readable.
  • Paths to know:
  • /var/lib/maid-engine/<instance>/ — engine state
  • /var/lib/maid-backups/ — local backup staging
  • /var/log/maid/ops-audit.jsonl — append-only audit log
  • /var/lib/maid/<instance>/ops/ — erasure intent + SQL drafts
  • Documentation prerequisites:
  • Your organization MUST have a GDPR erasure register (identity verified, request id, timestamp, legal-basis review).
  • You MUST have a documented retention period for backups (default is the longest of the on-host backup, off-host backup, and any WAL-archive tail). The erasure isn't complete until every backup tier has rolled over — see Step 8 of the Resolution.
  • Engine version pin: the core PII table list below is accurate for the M1.3 schema set (accounts/sessions/documents + stdlib game tables). Earlier or post-M1.4 engines may add or rename tables — re-derive the list from the live schema (Diagnostic Step 3) before running any UPDATE/DELETE.
  • Escalation contacts: see ./escalation-contacts.md.template.
  • Plan a downtime window of at least 30 minutes (single player) and up to 2 hours if any content pack flags manual_review PII (free-text body scans).

Symptoms

Summary

An identified data subject has invoked their GDPR Article 17 right to erasure. The request has been verified by your DPA / legal team. You are now the operator executing it on the live instance.

Detection

  • An out-of-band ticket from your DPA / legal team, NOT a monitor alert. There is no automated detection path; erasure is always operator-initiated.
  • Your erasure register entry is the canonical trigger — never start this runbook from a raw player message, only from a verified register entry with an erasure-request id.

Blast radius

  • Players affected: 1 (the data subject) directly; the player's social graph (friends, mail correspondents, channel co-subscribers) sees the player's name replaced with erased in retained messages.
  • Data at risk: all rows referencing the player's player_id across the core-engine PII tables AND every loaded content pack's declared PII tables. A mistake (wrong player_id, missing WHERE clause, wrong instance) is irreversible without a restore.
  • Engine impact: during Step 6 the erasure transaction blocks all writers on the affected tables. Recommended approach: take the engine down (Step 3) for the duration.
  • AI / NPC impact: NPC npc_memories, relationships, and knowledge_graph_facts rows referencing the player are deleted; any NPC dialogue cache that referenced the player by name needs to be flushed (Step 9 — engine restart drops the in-process cache).
  • Backup impact: all backups taken BEFORE the erasure still contain the player's data. The erasure is not complete until those backups have rolled out of retention (Step 8 of Resolution).

Diagnostic Steps

First 5 minutes (LITERAL commands)

# 1. Verify the request is real (your DPA register, NOT a player DM).
# Confirm:
#   - identity verified
#   - request_id, timestamp, operator name recorded in register
#   - legal-basis review complete (no investigation, no contractual
#     obligation, no public-interest exception)
# If any of those are missing: STOP. Do not proceed.

# 2. Resolve the player's IDs from the username.
#    The wrapper has no query-player verb yet (TODO M1.3+); use psql.
sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT player_id, account_id, created_at
    FROM accounts
   WHERE username = '<username>';"

# 3. Snapshot the live PII surface (so you can compare after Step 6).
PID='<player_id_from_step_2>'
sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT 'accounts' AS tbl, count(*) FROM accounts        WHERE player_id = '${PID}'
  UNION ALL SELECT 'sessions',  count(*) FROM sessions    WHERE player_id = '${PID}'
  UNION ALL SELECT 'documents', count(*) FROM documents   WHERE owner_player_id = '${PID}';"
# Record this count — Verification compares against it.

Investigation

Re-derive the PII table list from the live schema

The authoritative PII table list lives in packages/maid-engine/src/maid_engine/gdpr/core_pii.py (TODO M1.3+; not yet wired up). Until that ships, derive the list by querying information_schema on the live database:

sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT table_schema, table_name, column_name
    FROM information_schema.columns
   WHERE column_name IN ('player_id', 'account_id', 'owner_player_id',
                          'sender_player_id', 'recipient_player_id',
                          'subject_player_id', 'object_player_id',
                          'actor_player_id', 'reporter_player_id',
                          'from_player_id', 'to_player_id',
                          'attacker_player_id', 'defender_player_id',
                          'winner_player_id', 'loser_player_id')
   ORDER BY table_schema, table_name, column_name;"

Cross-check the resulting list against this expected core-engine set (the M1.3 ground truth). Any table in the live schema but NOT in this list belongs to a content pack and MUST be handled per that pack's PII policy (Limitations §1):

Table FK column(s) Action Notes
accounts player_id (PK) DELETE Account record itself is identifying.
account_emails account_id DELETE Email addresses.
account_auth_methods account_id DELETE Password hashes, OAuth tokens.
players player_id (PK) ANONYMIZE (preserve PK for FK integrity in audit log) Username, display name, real name, birth date.
characters player_id ANONYMIZE (name = 'erased-char-<hash>') Character names may be PII.
sessions player_id DELETE IP, user agent, connection timestamps.
session_login_history player_id DELETE Login geo, IP history.
audit_log actor_player_id ANONYMIZE actor only; keep event rows Audit log retained for compliance.
friends player_id, friend_player_id DELETE rows in either column Social graph.
friend_requests from_player_id, to_player_id DELETE
mail_messages (sender) sender_player_id ANONYMIZE sender_name/body Outbound mail from the erased player.
mail_messages (both ends) recipient_player_id AND sender_player_id = pid DELETE Stranger-to-erased mails are kept.
channel_messages sender_player_id ANONYMIZE sender_name/body Public chat.
channel_subscriptions player_id DELETE
relationships player_id DELETE NPC-player relationships.
npc_memories (FK) subject_player_id DELETE Episodic memory of the erased player.
npc_memories (text scan) (full-text body ILIKE '%<username>%') MANUAL REVIEW Memory bodies may mention the player by name.
knowledge_graph_facts subject_player_id, object_player_id DELETE NPC-held facts about the player.
quest_completion_log player_id DELETE or ANONYMIZE per content-pack policy Operator decision.
entities owner_player_id REASSIGN to NULL; entity may be deleted by content-pack cascade World objects owned by the player.
bug_reports reporter_player_id ANONYMIZE reporter; keep body Often technical, worth preserving.
feedback player_id ANONYMIZE author; keep body Same.

Enumerate content-pack PII

# List loaded content packs.
maid-admin status --instance <inst> --json | jq '.content_packs'

# For each pack, look up its pack.yaml manifest at
#   /opt/maid/current/packs/<pack_name>/pack.yaml
# and read the `pii_tables:` block. If the block is absent the pack
# either has no PII OR has not yet declared it — assume the latter and
# audit the pack's source manually.
# TODO(M1.3+): `maid-admin doctor --section=pii` will validate
# declarations automatically (per R7.A.11 Part B); not implemented today.

Audit log access check

# Verify you can append to the ops audit log before starting; an
# erasure with a missing audit trail is non-compliant.
sudo tail -n 1 /var/log/maid/ops-audit.jsonl > /dev/null \
  && echo "audit log readable" \
  || echo "ERROR: audit log not readable — fix BEFORE Step 6"

Resolution Steps

Step 1 — Verify the request

Confirm the register entry: identity verified, request id assigned, timestamp recorded, legal-basis review complete. If any of those are missing, STOP and return to your DPA.

Step 2 — Capture the player's IDs

From the Diagnostic Steps you already have player_id, account_id, and the live PII surface counts. Save them to your erasure register and to the local intent file:

mkdir -p /var/lib/maid/<inst>/ops
cat > /var/lib/maid/<inst>/ops/erasure-<player_id>.intent <<EOF
request_id: <register-id>
player_id:  <player_id>
account_id: <account_id>
operator:   <your-username>
started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
chmod 0640 /var/lib/maid/<inst>/ops/erasure-<player_id>.intent

The erasure transaction blocks all writers on the affected tables. The cleanest path is to take the engine down for the duration. Notify players per ../deployment/player_comms.md if you have an active player base, then:

sudo systemctl stop maid-engine@<inst>.service

If you absolutely cannot take downtime, schedule for off-peak and warn yourself: a long UPDATE on channel_messages will hold table locks long enough to stall the engine's writes — /readyz may drop to 503 mid-procedure.

Step 4 — Capture a pre-erasure backup

The backup is your only rollback path. Do NOT skip this.

# Use the actual backup script — see RB5 / backup_failed.md for the
# canonical invocation. `backup.sh` has NO --label flag (it rejects
# unknown flags); capture the id the run emits via --json instead, and
# verify THAT id. On any failure `full` exits non-zero and prints no id,
# so BID stays empty and we abort.
# backup.sh reads MAID_DEPLOY_BACKUP_REMOTE from its OWN process env; a
# bare sudo does NOT load the instance EnvironmentFile, so source it with
# auto-export inside the maid-engine shell (else source_transport aborts:
# "MAID_DEPLOY_BACKUP_REMOTE is not set").
BID="$(sudo -u maid-engine --preserve-env=MAID_SRC bash -c \
  'set -a; . /etc/maid/<inst>.env; set +a; exec "${MAID_SRC}/deploy/scripts/backup.sh" full --instance <inst> --json' \
  | jq -r '.backup_id // empty')"
test -n "${BID}" || { echo "pre-erasure backup did not complete — aborting"; exit 1; }
export BID

# Verify the backup completed and is restorable (verify requires an
# explicit --backup-id; there is no --label/--latest).
sudo -u maid-engine --preserve-env=BID,MAID_SRC bash -c \
  'set -a; . /etc/maid/<inst>.env; set +a; exec "${MAID_SRC}/deploy/scripts/backup.sh" verify --instance <inst> --backup-id "${BID}"' \
  || { echo "verify of ${BID} failed — aborting"; exit 1; }

Record the backup id (${BID}) and its on-disk path. You will need both for Step 8 and (if anything goes wrong) for restore. There is no retention "tag": to stop the GDPR-cycle backup from being auto-pruned, copy it aside or exclude that id from the prune job manually (backup.sh prune has no per-id keep flag).

Step 5 — Draft the erasure SQL

Open a text editor and write the SQL by hand from the Diagnostic-step table list, parametrized on :pid. A skeleton (verify each table exists in YOUR schema before pasting — see Limitations §2):

-- /var/lib/maid/<inst>/ops/erasure-<player_id>.sql
\set ON_ERROR_STOP on
\set pid '''<player_id>'''
\set request_id '''<register-id>'''
\set operator '''<your-username>'''

BEGIN;

-- Hard deletes
DELETE FROM account_emails        WHERE account_id = :pid;
DELETE FROM account_auth_methods  WHERE account_id = :pid;
DELETE FROM sessions              WHERE player_id  = :pid;
DELETE FROM session_login_history WHERE player_id  = :pid;
DELETE FROM friends               WHERE player_id  = :pid OR friend_player_id = :pid;
DELETE FROM friend_requests       WHERE from_player_id = :pid OR to_player_id = :pid;
DELETE FROM channel_subscriptions WHERE player_id  = :pid;
DELETE FROM relationships         WHERE player_id  = :pid;
DELETE FROM npc_memories          WHERE subject_player_id = :pid;
DELETE FROM knowledge_graph_facts WHERE subject_player_id = :pid
                                     OR object_player_id  = :pid;
DELETE FROM mail_messages         WHERE recipient_player_id = :pid
                                    AND sender_player_id     = :pid;

-- Anonymize (keep PK so audit FKs survive)
UPDATE players SET
    username   = 'erased-' || substr(player_id::text, 1, 8),
    email      = NULL,
    real_name  = NULL,
    birth_date = NULL,
    erased_at  = now(),
    erased_by  = 'gdpr-' || to_char(now(), 'YYYYMMDD')
  WHERE player_id = :pid;

UPDATE characters     SET name = 'erased-char-' || substr(character_id::text, 1, 8)
                      WHERE player_id = :pid;
UPDATE mail_messages  SET sender_name = 'erased',
                          body        = '[message erased per GDPR request]'
                      WHERE sender_player_id = :pid;
UPDATE channel_messages SET sender_name = 'erased',
                            body        = '[message erased per GDPR request]'
                      WHERE sender_player_id = :pid;
UPDATE audit_log      SET actor_username = 'erased'
                      WHERE actor_player_id = :pid;
UPDATE bug_reports    SET reporter_player_id = NULL,
                          reporter_username  = 'erased'
                      WHERE reporter_player_id = :pid;
UPDATE feedback       SET player_id = NULL,
                          author    = 'erased'
                      WHERE player_id = :pid;
UPDATE entities       SET owner_player_id = NULL
                      WHERE owner_player_id = :pid;

-- Last: write the audit event (so it survives even if the player row
-- gets renumbered later).
INSERT INTO audit_log (event_type, payload, occurred_at)
VALUES ('player_data_erased',
        jsonb_build_object('player_id', :pid,
                           'request_id', :request_id,
                           'operator',   :operator),
        now());

COMMIT;

Save with chmod 0640 /var/lib/maid/<inst>/ops/erasure-<player_id>.sql. Append the same table list for every loaded content pack's declared PII (see Diagnostic Steps). If a pack has manual_review PII (free- text body scans), defer those to Step 7 — do NOT include them in this transaction.

Step 6 — Execute the erasure

Read the SQL one more time. Then:

sudo -u postgres psql -d "maid_<inst>" \
    --single-transaction \
    --set ON_ERROR_STOP=1 \
    --file /var/lib/maid/<inst>/ops/erasure-<player_id>.sql

# Capture exit status. On success, mark the intent done.
if [ $? -eq 0 ]; then
  mv /var/lib/maid/<inst>/ops/erasure-<player_id>.intent \
     /var/lib/maid/<inst>/ops/erasure-<player_id>.done
else
  mv /var/lib/maid/<inst>/ops/erasure-<player_id>.intent \
     /var/lib/maid/<inst>/ops/erasure-<player_id>.aborted
  # STOP. Go to Failure recovery (Escalation).
fi

Step 7 — Resolve manual-review tables

If any content pack flagged manual_review PII (typically free-text npc_memories.body scans), enumerate the candidate rows and decide per-row whether to redact or delete:

sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT memory_id, body
    FROM npc_memories
   WHERE body ILIKE '%<original_username>%';"

# Per-row, in a new transaction:
sudo -u postgres psql -d "maid_<inst>" -c "
  BEGIN;
  UPDATE npc_memories
     SET body = '[REDACTED per GDPR request]'
   WHERE memory_id = '<memory_id>';
  COMMIT;"

Record the decisions in your erasure register so the auditor can verify. The NPC dialogue cache will pick up the new text on the next LLM call after the engine restarts (Step 9).

Step 8 — Erasure of backups

Backups taken BEFORE Step 6 still contain the player's data. The MVP-supported approach is natural expiry (waiting one full retention period). Document the schedule in your erasure register:

Tier Retention default Action
On-host (/var/lib/maid-backups/) 14 days Mark all pre-erasure backups; let retention prune them.
Off-host (S3/SSH) 90 days (monthly + weekly + daily) Same; wait for the longest tier to roll over.
WAL archive until next base backup Will be replaced on the next full backup cycle.
The Step-4 baseline backup (recorded ${BID}) Delete it once all other pre-erasure backups have aged out — this is the LAST tier to clear.

Three trade-offs to know about (the second is what MAID does today):

  1. Crypto-shred (destroy a per-backup encryption key) — fastest; unavailable: MAID's backup pipeline performs no encryption at all (backup.sh reads no MAID_BACKUP_ENCRYPTION_KEY*; confidentiality depends on the transport/destination, integrity on SHA-256 sidecars), so there is no key to destroy. Post-MVP backlog.
  2. Natural expiry — what this runbook does. Compliant if your register documents the expected horizon and the player is told.
  3. Re-encrypt-without-subject — extract, re-mask, re-encrypt every archived backup. Expensive (re-walks every archive), requires restoring each backup into a scratch cluster. Operator MAY do this for high-priority requests; not automated today.

Step 9 — Restart the engine

sudo systemctl start maid-engine@<inst>.service
maid-admin doctor --phase runtime --instance <inst>
# All green expected.

If doctor reports any errors, do NOT allow players to reconnect; investigate first. Common: stale application_name rows in pg_stat_activity left from the pre-erasure session — they should self-clear on engine restart.

Recovery

  • maid-admin doctor --phase runtime --instance <inst> returns all green.
  • /readyz returns 200.
  • maid-admin status --instance <inst> shows engine_state=running.
  • Verification queries (run all three; all must hold):

# 1. Player is anonymized, not deleted.
sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT erased_at, username FROM players WHERE player_id = '<pid>';"
# → 1 row, erased_at NOT NULL, username starts with 'erased-'.

# 2. No live PII surface remains (compare against Diagnostic Step 3
#    snapshot — all DELETE-table counts should now be 0).
sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT 'sessions',  count(*) FROM sessions  WHERE player_id  = '<pid>'
  UNION ALL SELECT 'friends',  count(*) FROM friends  WHERE player_id  = '<pid>'
  UNION ALL SELECT 'npc_mem',  count(*) FROM npc_memories
                                        WHERE subject_player_id = '<pid>';"
# → all zero.

# 3. Audit event was written.
sudo -u postgres psql -d "maid_<inst>" -c "
  SELECT occurred_at, payload->>'request_id' AS request_id
    FROM audit_log
   WHERE event_type = 'player_data_erased'
     AND payload->>'player_id' = '<pid>';"
# → 1 row with your register's request_id.
- The erasure-<player_id>.done file is present in /var/lib/maid/<inst>/ops/. - No .aborted files for this erasure.

Post-incident

  • Update your erasure register with the completion timestamp, the Step-4 backup id (${BID}), the Step-8 backup-expiry horizon, and the Step-7 manual-review decision count.
  • File a ticket for any content pack whose PII declaration was missing or wrong (the M1.3+ pii_tables: manifest will eventually reject these at pack load — see Limitations §1).
  • If the SQL needed a hand-edit because a table was missing or renamed since the table list in this runbook was last reviewed, update the Diagnostic Steps table list in this runbook.
  • Notify the player (or the requesting representative) that the on-DB erasure is complete and quote the backup-expiry horizon from Step 8 as the date by which all backup copies will have expired.

Limitations of current implementation

The following automation does not yet exist; this runbook compensates with manual SQL. Track in your operator backlog:

  1. No maid-admin gdpr erase-player verb. The wrapper at packaging/admin/wrapper.py:332-396 supports only status, doctor, reload, restore, rotate-credentials, instance. The plan calls this out as a post-MVP add (plan.md R6.A.11, R7.A.11). Until it ships you MUST write the SQL by hand from the Diagnostic-step table list.
  2. No core_pii.py declaration module. The canonical PII table list is currently inlined in this runbook; the M1.3+ work to move it to packages/maid-engine/src/maid_engine/gdpr/core_pii.py is tracked in plan.md R7.A.11 Part A.
  3. No content-pack pii_tables: manifest validation. Content packs MAY declare PII but maid-admin doctor --section=pii is not implemented; you MUST audit pack source manually (R7.A.11 Part B).
  4. No per-backup encryption keys. Crypto-shred (trade-off 1 in Step 8) is unavailable; natural expiry is the only compliant path today.
  5. No maid-admin backup list --account <id>. You cannot selectively enumerate backups containing a specific player; the Step-8 schedule is whole-tier, not per-player.

When any of the above ships, update this runbook to use the new tool and remove the relevant Limitation entry.

Escalation

# Engine is down, so the in-game broadcast is unavailable and `maid ops
# maintenance` is an M9 stub. Post to the configured webhook(s). The
# webhook URL is in the instance env file; `maid ops announce` reads
# MAID_BRIDGES_WEBHOOK_URLS from its OWN process environment, so source
# the env file with auto-export (`set -a`) — a bare --preserve-env of a
# never-exported var forwards nothing:
sudo -u maid-engine --preserve-env=MAID_SRC bash -c \
  'set -a; . /etc/maid/<inst>.env; set +a;
   exec "${MAID_SRC}/.venv/bin/maid" ops announce --severity warn \
     --message "GDPR erasure in progress, ETA <ETA>"'