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(frompostgresql-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
sudogrant — erasure runssudo -u postgres …andsudo -u maid-engine …service-user shells andsudo systemctlon the instance units, none of which the narrowmaid-adminpriv-helper allowlist (packaging/sudoers/maid-admin.template) grants (it only covers fixedmaid-admin <verb>calls, and nomaid-admin gdpr erase-playerverb exists yet — see Limitations). There is nomaid-opsgroup in the packaging — only themaid-admingroup plus themaid-engineservice user (the restore/cleanup units run asroot). - Source checkout (
MAID_SRC):deploy/scripts/backup.shis repo-only and is not staged under/opt/maid/currentuntil M1.4, soexport MAID_SRC=/path/to/maid-checkoutbefore the backup steps below; they invoke"${MAID_SRC}/deploy/scripts/backup.sh"and forwardMAID_SRCthroughsudowith--preserve-env=MAID_SRC. Today you need directsudo -u postgres psqlor equivalent. - The same
MAID_SRCcheckout also provides themaidCLI (there is no system-widemaiduntil 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>.envreadable. - 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_reviewPII (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
erasedin retained messages. - Data at risk: all rows referencing the player's
player_idacross the core-engine PII tables AND every loaded content pack's declared PII tables. A mistake (wrongplayer_id, missingWHEREclause, 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, andknowledge_graph_factsrows 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
Step 3 — Schedule downtime (recommended)¶
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:
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):
- Crypto-shred (destroy a per-backup encryption key) — fastest;
unavailable: MAID's backup pipeline performs no encryption at all
(
backup.shreads noMAID_BACKUP_ENCRYPTION_KEY*; confidentiality depends on the transport/destination, integrity on SHA-256 sidecars), so there is no key to destroy. Post-MVP backlog. - Natural expiry — what this runbook does. Compliant if your register documents the expected horizon and the player is told.
- 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./readyzreturns 200.maid-admin status --instance <inst>showsengine_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.
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:
- No
maid-admin gdpr erase-playerverb. The wrapper atpackaging/admin/wrapper.py:332-396supports onlystatus,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. - No
core_pii.pydeclaration module. The canonical PII table list is currently inlined in this runbook; the M1.3+ work to move it topackages/maid-engine/src/maid_engine/gdpr/core_pii.pyis tracked in plan.md R7.A.11 Part A. - No content-pack
pii_tables:manifest validation. Content packs MAY declare PII butmaid-admin doctor --section=piiis not implemented; you MUST audit pack source manually (R7.A.11 Part B). - No per-backup encryption keys. Crypto-shred (trade-off 1 in Step 8) is unavailable; natural expiry is the only compliant path today.
- 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¶
- Solo path: stop the bleeding (do NOT retry Step 6 after an
.abortedintent file without root-cause analysis), restore from the Step-4 backup per ./restore.md, file a ticket describing why the erasure failed. - Hosting console URL: see ./escalation-contacts.md.template.
- DPA contact: see ./escalation-contacts.md.template.
- Comms channel URL: see ./escalation-contacts.md.template.
- Peer operator: see ./escalation-contacts.md.template.
- If recovery takes >30 minutes (engine still down after Step 9), notify players externally and post to the comms channel per ../deployment/player_comms.md:
# 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>"'