Skip to content
Back to skills

Pii Copy Scan

ASecurity

Personal data copied into models, rollups, snapshots and exports; classification, erasure reach, masking.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentsgojavaphpbashsqlnodedockerawsazureterraform

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill pii-copy-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pii Copy Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pii Copy Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-pii-copy-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-pii-copy-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: pii-copy-scan
description: "Personal data copied into models, rollups, snapshots and exports; classification, erasure reach, masking."
kind: skill
pack: data-engineering
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: pii-copy-scan

## Premise

Personal data is protected where it is stored first: the customers table has its access rules, its retention job and its erasure path. Every **copy** escapes them unless someone carries them along: a mart that selects `email`, a dbt snapshot that keeps every historical version of a customer row (SCD2 history survives the erasure of the source row), a rollup keyed by a phone number, a nightly CSV left in a bucket with no lifecycle, a production dump restored into staging, a "safe to share" column that is an unsalted SHA-256 of an email (a dictionary reverses it, and pseudonymised data stays personal data — EDPB Guidelines 01/2025 on pseudonymisation, consultation version 2025-01-16; ICO guidance on the right to erasure, accessed 2026-10-19). Masking can be configured and still not apply: dbt `policy_tags` reach BigQuery only when `persist_docs` writes the columns, and a Snowflake tag-based masking policy applies only to columns whose type matches the policy's.

This skill owns research ids PC-1…PC-6 (`research-data-engineering.md` § 1.8), shipped as `DE-PC-<n>`: **personal data in analytical and exported copies — whether it is classified, reached by erasure, masked where it claims to be, and kept out of lower environments.**

**Every finding cites the copy `<file:line>` (model, snapshot, rollup DDL, export mapper, dump script, bucket) + the personal-data column and its source + the classification found (or none) + the erasure path read (or its absence) + the oracle that would settle it + the fix in the project's own primitive.** A grep hit is a candidate. "Personal data looks protected" is never printed; the output is the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md); the oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Data engineering, V565–V567.

## The rule

A candidate is a finding when all four hold:

1. **The copy exists and is reachable.** A model is built (not `enabled: false`), a snapshot runs, a rollup is written, an export is produced or pushed, a dump is restored. Seed CSVs with synthetic data fail here.
2. **The column is personal data of a real subject.** An `email`, phone, name, address, national id, IP or birth date of a customer, user or employee — not a merchant's business support address, a system sender, or a value already reduced to an aggregate with no identifier.
3. **No existing control, across files.** A classification the platform enforces (dbt `meta`, a policy tag persisted to the column, a masking policy), an erasure job whose table list covers the copy (or an FK `on delete cascade`, or a trigger that deletes the derived rows), a keyed HMAC with the key in a KMS (precedent § 31), a masking step in the restore pipeline, a lifecycle rule on the bucket, the controller exporting its own data to its own destination (precedent § 34).
4. **The oracle fired, or the fact is two-ended.** V565 (erase a seeded subject, scan every copy) or V567 (a dictionary of common emails hashed with the repo's function finds join hits) ran red on a scratch target; or the static fact cites both ends — the erasure job's table list and the derived table that carries the identifier; the PII column in the model and the absence of any classification for it.

## Boundaries

- **OLTP storage, retention and the erasure mechanics of the source tables** → database `schema-integrity-scan` §10 (when installed); this skill reads its erasure-path row and checks the copies. **Who may read or export personal data, PII in logs, traces and error trackers** → security `data-exposure-scan` §6 and `@data-privacy-reviewer`. **Analytics SDK payloads** (Segment, Mixpanel event properties) → security and product `tracking-plan-scan`. **Backups** (retention, encryption, restore) → infrastructure `dr-audit`; precedent § 32 covers erased subjects in rotating backups. Each when installed; without them the row is `[report-flagged]` here with the route named.
- **A copy that is wrong for a non-privacy reason** (stale, doubled) → `derived-data-scan` / `incremental-load-scan`; **an export's consistency** → `extract-scan`. Deletes that never reach a warehouse table are `incremental-load-scan` §4; when the deleted row is a subject's personal data the row is cited here as §2 too.
- **This skill never decides a legal basis or a retention period.** A missing policy is `UNDECLARED`, printed as an enabler; `/data-fix` asks before writing one.

## Adapt to the codebase — the lanes

| Lane | Detect by | Classification / erasure idioms read | Fix primitive |
|---|---|---|---|
| **dbt** (any adapter) | `dbt_project.yml`; `models/**/*.yml`; `snapshots/` | column `meta: {pii: true}` / `contains_pii`, `tags: [pii]`, `policy_tags:` (BigQuery) + `persist_docs: {columns: true}`, Snowflake masking policies and tags, Databricks column masks | classify in YAML; mask or drop in staging; `hard_deletes: invalidate` + an erasure macro over snapshots |
| **In-app derived tables** (Postgres/MySQL; any ORM) | migrations and entities of `*_daily`, `*_stats`, `*_snapshots`, `*_exports`, analytics/reporting tables | the erasure handler's table list; FK `on delete cascade`; delete triggers | carry the subject id and cover it in the erasure job, or keep only aggregates |
| **Exports and pushes** | export runners and mappers (`extract-scan` 0a/0b), storage buckets, third-party clients | retention on stored files; destination ownership | expire stored files; push only the controller's own data to its own destination |
| **Lower environments** | `pg_dump`/`pg_restore`/`mysqldump` scripts, CI jobs, Makefiles, seed-from-prod scripts | a masking step (`pg_anonymizer`/`anon.`, a masking view, `--exclude-table-data`, faker) | restore through a masking step; synthetic seeds |
| **Hashing** | `createHash`, `hashlib`, SQL `md5`/`sha2` on identifiers | key, salt, pepper, HMAC | keyed HMAC with a managed key; treat the output as personal data |
| **IaC** (Terraform, Pulumi, CloudFormation) | `aws_s3_bucket`, `google_storage_bucket`, BigQuery datasets | lifecycle rules, `partition_expiration_days`, `default_table_expiration_ms` | a lifecycle rule; table expiration on raw landing |

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe). Grep **N** feeds §N; 0 builds the surface. Every `rg` names its path; include globs first, exclusions last, every exclusion `**/`-rooted; two **path** globs may stand together (they are meant to OR) but a path glob is never combined with an extension glob. Every grep ends in `ck`: rg exit 2 is a **blocked** class, never zero hits. **Migrations, CI files and IaC are in scope.** Nothing is written outside `$OUT`; no personal value is printed — the greps print column names and lines of code, and a probe that would sample rows prints counts only.

```bash
ROOTS=(.)   # monorepo: name the analytics, app and infra roots, e.g. ROOTS=(analytics apps/api/src infra)
OUT="${OUT:-$(mktemp -d "${TMPDIR:-/tmp}/de-pc.XXXXXX")}"; mkdir -p "$OUT/none"
h() { printf '## %s\n' "$1"; }; ck() { [ "$2" -lt 2 ] || echo "$1 BLOCKED (rg exit $2) — the class is blocked, never 0 hits"; }
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/{dist,build,out,target,coverage,.next,.venv,venv,dbt_packages,logs}/**'
   -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata}/**'
   -g '!**/*.{spec,test,e2e-spec,stories,d}.*' -g '!**/test_*.py' -g '!**/*_test.{py,go}'
   -g '!**/{locales,locale,i18n,translations}/**' -g '!**/{docs,ai,.claude}/**' -g '!**/*.{md,mdx,lock,min.js,snap}')
CODE=(-g '*.{ts,mts,js,mjs,cjs,py,rb,php,java,kt,go,cs,sql}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(?://|/\*|\*(?:\s|/|$)|#|--)')
PV='(?i)\b(?:\w+_)?(?:e_?mail(?:_?address)?|phone(?:_?number)?|mobile(?:_?number)?|msisdn|ip_?address|first_?name|last_?name|full_?name|customer_?name|address(?:_?line_?\d)?|street(?:_?address)?|dob|date_?of_?birth|birth_?date|national_?id|ssn|iban|passport(?:_?(?:no|number))?|tax_?id)\b'
# 0 THE COPY SURFACE — 0a dbt projects · 0b analytical/derived tables in migrations · 0c erasure handlers · 0d export and dump files
h 0a; rg --files -g '**/dbt_project.yml' "${X[@]}" "${ROOTS[@]}"; ck 0a $?
AN='((?:\w+_)?\w+_(?:daily|hourly|weekly|monthly|summary|summaries|stats|rollups?|agg|aggregates?|totals|facts|snapshots?|history|exports?|extracts?|report\w*|analytics|events_flat)|(?:analytics|reporting|warehouse|dw|mart)_\w+)'
h 0b; rg -n -U --pcre2 "(?i)\\bcreate\\s+table\\s+(?:if\\s+not\\s+exists\\s+)?[\"\\x27\`]?$AN(?!\\w)[\"\\x27\`]?\\s*\\([^;]{0,4000}?$(printf '%s' "$PV" | sed 's/^(?i)//')" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | rg -o --pcre2 "(?i)^[^:]+:\\d+:.*?create\\s+table\\s+(?:if\\s+not\\s+exists\\s+)?[\"\\x27\`]?\\w+" | sort -u | tee "$OUT/PCT.txt"; ck 0b ${PIPESTATUS[0]}
h 0c; rg -l --pcre2 '(?i)\b(?:erase|erasure|anonymi[sz]e\w*|right_?to_?be_?forgotten|forget(?:Customer|User|Subject)\w*|redact(?:Customer|User|Subject)\w*|gdpr|dsar|purge(?:Customer|User|Account|Subject)\w*)\b' "${CODE[@]}" "${X[@]}" -g '!**/{migrations,migration}/**' "${ROOTS[@]}" | sort -u | tee "$OUT/ERASE.txt"; ck 0c ${PIPESTATUS[0]}
h 0d; rg --files -g '**/*{export,Export,sheet,Sheet,dump,Dump,extract,Extract,backup,Backup,seed,Seed,restore,Restore}*' "${X[@]}" "${ROOTS[@]}" | rg -v --pcre2 '\.(?:md|json|csv|txt|svg|png)$' | sort -u > "$OUT/EXP.txt"; wc -l < "$OUT/EXP.txt"
E=(); while IFS= read -r f; do E+=("$f"); done < "$OUT/EXP.txt"; [ ${#E[@]} -gt 0 ] || E=("$OUT/none")
# 1 PC-1 classification — 1a dbt YAML files that declare a PII-named column and carry no classification · 1b PII selected in marts/snapshots SQL · 1c 0b's tables (read)
h 1a; rg -l --pcre2 "^\\s*-\\s*name:\\s*[\"\\x27]?$(printf '%s' "$PV" | sed 's/^(?i)//')[\"\\x27]?\\s*\$" -i -g '**/models/**/*.{yml,yaml}' -g '**/snapshots/**/*.{yml,yaml}' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?i)\b(?:pii|contains_pii|personal_data|sensitive|classification|policy_tags|masking_policy|mask)\b'
h 1b; rg -n -w --pcre2 "$PV" -g '**/models/marts/**' -g '**/models/intermediate/**' -g '**/snapshots/**' -g '**/models/**/*{mart,fct,dim,rpt,agg}*' "${X[@]}" "${ROOTS[@]}" | rg --pcre2 '\.sql:' | "${NC[@]}" | head -60; ck 1b ${PIPESTATUS[0]}
# 2 PC-2 erasure reach — 2a the tables the erasure handlers touch · 2b dbt snapshots (SCD2 history) of any source · 2c 0b tables the handlers never name · 2d Delta deletes with no VACUUM
ERS=(); while IFS= read -r f; do ERS+=("$f"); done < "$OUT/ERASE.txt"; [ ${#ERS[@]} -gt 0 ] || ERS=("$OUT/none")
h 2a; rg -o -I --no-filename --pcre2 -r '$1$2' '(?i)\b(?:delete\s+from|update|truncate(?:\s+table)?)\s+"?(\w+)"?|\bnative(?:Delete|Update)\(\s*(\w+)' "${ERS[@]}" | sort -u | tee "$OUT/ERASED.txt" | tr '\n' ' '; echo
h 2b; rg -l --pcre2 '\{%-?\s*snapshot\s+\w+|^\s*snapshots\s*:' -g '**/snapshots/**' "${X[@]}" "${ROOTS[@]}"; ck 2b $?
h 2c; sed -E 's/.*create[[:space:]]+table[[:space:]]+(if[[:space:]]+not[[:space:]]+exists[[:space:]]+)?["`'"'"']?//I' "$OUT/PCT.txt" | sort -u | while IFS= read -r t; do [ -n "$t" ] || continue; grep -qixF "$t" "$OUT/ERASED.txt" && echo "$t erased-by-handler" || echo "$t NOT named by any erasure handler (read FKs/cascades/triggers)"; done
h 2d; rg -l --pcre2 '(?i)\bdelete\s+from\b[^;\n]*' -g '*.{py,sql,scala}' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg -l --pcre2 '(?i)\bdelta\b|DeltaTable' | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?i)\bvacuum\b|reorg\s+table[^;]*apply\s*\(\s*purge'
# 3 PC-3 pseudonymisation treated as anonymisation — unkeyed hashes of identifiers; columns named anon* fed by them
h 3; rg -n --pcre2 '(?i)createHash\(\s*["\x27](?:md5|sha1|sha256|sha512)["\x27]\s*\)\s*\.update\([^)]*(?:email|phone|mobile|msisdn|national|ssn)|\bhashlib\.(?:md5|sha1|sha256|sha512)\([^)]*(?:email|phone|mobile|msisdn|ssn)|\b(?:md5|sha1|sha256|sha2|sha512|farm_fingerprint|hash)\s*\(\s*(?:lower\s*\(\s*)?(?:trim\s*\(\s*)?(?:\w+\.)?\w*(?:email|phone|mobile|msisdn|ssn)\w*|\b\w*anon\w*\s*(?:=|:|as)\s*[^\n]*(?:md5|sha1|sha256|createHash|hashlib)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | rg -v --pcre2 '(?i)\bhmac\b|createHmac|\bsalt\b|\bpepper\b|\bsecret\b|\bkms\b' | "${NC[@]}"; ck 3 ${PIPESTATUS[0]}
# 4 PC-4 production copied down — dump/restore/seed-from-prod pipelines with no masking step in the same file
h 4; rg -l --pcre2 '(?i)\bpg_dump\b[^\n]*(?:\|\s*psql|-f)|\bpg_restore\b|\bmysqldump\b[^\n]*\|\s*mysql|\bseed[_-]?from[_-]?prod\w*|\bcopy[_-]?prod\w*|\b(?:prod|production)\b[^\n]{0,80}\b(?:staging|stage|dev|qa|preview)\b[^\n]{0,80}\b(?:restore|load|import|sync)\b' -g '*.{sh,bash,py,ts,js,rb,yml,yaml,toml}' -g '**/Makefile' -g '**/Dockerfile*' --hidden "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?i)\bpg_anonymi[sz]er\b|\banon\.\w+|\banonymi[sz]e\b|\bmask\w*|\bfaker\b|\bscrub\w*|\bsanitiz\w*|--exclude-table-data'
# 5 PC-5 stored or pushed exports — 5a buckets/datasets with no lifecycle or expiry (IaC) · 5b export files that store a file and name no expiry · 5c third-party destinations in export files (read § 34)
h 5a; rg -l --pcre2 '\bresource\s+"(?:aws_s3_bucket|google_storage_bucket|google_bigquery_dataset|azurerm_storage_container)"\s+"\w*(?:export|extract|report|dump|landing|raw|analytics)\w*"' -g '*.{tf,tf.json}' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 'aws_s3_bucket_lifecycle_configuration|lifecycle_rule|partition_expiration_days|default_table_expiration_ms|default_partition_expiration_ms'
h 5b; rg -l --pcre2 '(?i)\b(?:putObject\w*|upload\w*|writeFile|createWriteStream|save_to|blob\.upload|bucket\.file)\s*\(' "${E[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?i)\b(?:expires?_?(?:at|in)|ttl|retention|lifecycle|delete_?after|purge\w*|cleanup\w*|prune\w*)\b'
h 5c; rg -n --pcre2 '(?i)\bgoogleapis\b|sheets\.spreadsheets|\bsftp\b|\bhubspot\b|\bmailchimp\b|\bsalesforce\b|\bsegment\b|\bamplitude\b|\bmixpanel\b|\bintercom\b|\bklaviyo\b' "${E[@]}" | "${NC[@]}" | head -20; ck 5c ${PIPESTATUS[0]}
# 6 PC-6 masking configured but not effective — policy_tags with no persist_docs columns · masking policies and their tagged columns (read the types)
h 6a; rg -l --pcre2 '\bpolicy_tags\s*:' -g '*.{yml,yaml,sql}' "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do echo "$f"; done; rg -n -U --pcre2 'persist_docs\s*[:=]\s*\{?[\s\S]{0,80}?columns\s*[:=]\s*(?:true|True)' -g '*.{yml,yaml,sql}' "${X[@]}" "${ROOTS[@]}"; ck 6a $?
h 6b; rg -n --pcre2 '(?i)\bcreate\s+(?:or\s+replace\s+)?masking\s+policy\s+[\w.]+\s+as\s*\(\s*\w+\s+(\w+)[^)]*\)\s*returns\s+\w+|\bset\s+masking\s+policy\b|\bset\s+tag\b|\balter\s+tag\b[^;]*\bset\s+masking\s+policy\b' -g '*.{sql,yml,yaml,tf,py}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 6b ${PIPESTATUS[0]}
```

2c prints each 0b table with a PII column and whether any erasure handler names it: `NOT named` is a §2 candidate until a foreign key `on delete cascade`, a delete trigger, or a whole-period recompute that drops the subject's rows is read (a real repo closes its rollup's erasure through an `after delete` trigger on the source — read the triggers before reporting). 2b lists every dbt snapshot: a snapshot of a PII-bearing source keeps history after erasure unless an erasure macro or `hard_deletes` plus a scrub runs over it.

### Measured (2026-10-19)

- **Fixture** (`benchmarks/fixtures/data-pipelines`, `ROOTS=(.)`; under 1 s, 0 `rg` errors; no personal value printed): 0b 1 derived table with a PII column (`customer_ltv_daily`) · 0c 1 erasure handler · 0d 17 export/dump/seed files · 1a 1 (the customer dimension's model YAML; the classified contacts file is excluded) · 1b 14 lines · 2a the handler's tables (`customers orders sessions`) · 2b 2 snapshots · 2c 1 `NOT named` · 3 2 (an unkeyed SHA-256 in a partner feed and in a mart; the keyed HMAC is not listed) · 4 2 (a `pg_dump | psql` into staging, a `pg_restore` target; the masked preview restore is excluded) · 5a 1 bucket without lifecycle (the archive bucket with one is excluded) · 5b 2 · 5c 2 (the merchant's own-data sheet push, precedent § 34) · 6a 1 (`policy_tags`, no `persist_docs`) · 6b 4 (a string masking policy tagged onto a NUMBER column).
- **Real repo, read-only** (the NestJS monorepo, `ROOTS=(apps/api/src)`; 0.4 s; nothing written): 0a 0 (no dbt) · 0b 0 — no derived table carries a PII-named column; the order-day rollup holds counts and amounts, and its erasure is closed by the delete trigger on orders · 0c 20 files (customer and order erasure ports, a purge executor, and words like `purge` in unrelated modules — read) · 2a names entity classes (`CustomerEntity`, `CustomerPhoneEntity`, …) because the handlers delete through the ORM; the read maps them to tables · 3 0 · 4 0 in code (dumps are an operator download, `extract-scan` §2) · 5–6 0. The purge manifest's sentence that "no daily rollup table" exists is documentation drift (documentation `doc-drift-scan`), not PC-2. **First drafts this measurement corrected:** the PII vocabulary missed prefixed columns (`customer_email`) behind a `\b`; the upload grep missed the AWS SDK v3 `PutObjectCommand`; the SQL hash grep missed qualified columns (`lower(c.email)`).
- **The dbt, IaC and masking-policy lanes are fixture-measured only.**

## The 3,000-hit path

1. **Build the copy inventory once**: every model, snapshot, derived table, export and dump that carries a PII-named column (0a, 0b, 1b, 0d). Group by **source column** — one `customers.email` flowing into twelve marts is one classification decision at the staging model.
2. **Join it with the erasure path once** (0c, 2a): the copies the handler never names are the §2 list; read cascades and triggers for each before it is a candidate.
3. **Rank** by exposure: copies that leave the database (exports, pushes, lower environments, shared datasets) first; internal marts with access control next; aggregates last.
4. **Prefer erase-then-scan at scale** (V565): seed one subject with distinctive values, run the erasure and every build, then grep every derived table, snapshot, refreshed view and stored export for those values — one run settles the whole §2 list.
5. **Ledger the rest** as `Live, unreviewed (k copies / n hits)` with the command that continues them. Never `checked`.

## Scans for

Severity assumes a real subject's data. One step lower when the copy is access-controlled and internal; a copy that leaves the organisation (a push, a shared bucket, a lower environment) stays at its default or higher.

| § | Class (`DE-` id) | Default severity | Oracle |
|---|---|---|---|
| 1 | PC-1 PII in models, rollups, snapshots or landing tables with no classification | medium | V566 |
| 2 | PC-2 erasure does not reach derived copies (snapshots, rollups with identifiers, stored exports, landing tables) | high | V565 |
| 3 | PC-3 pseudonymisation treated as anonymisation | medium | V567 |
| 4 | PC-4 production data copied to a lower environment without masking | high | V567 |
| 5 | PC-5 stored or pushed exports with no retention or access rule | medium | V565 |
| 6 | PC-6 masking configured but not effective | medium | V566 |

### 1. Unclassified personal data in an analytical copy

```
BAD:   models/marts/dim_customers.yml:  - name: email   (no meta, no policy tag)
       create table customer_ltv_daily (day date, customer_email text, ltv numeric)
GOOD:  - name: email  meta: {contains_pii: true}  policy_tags: ['…/pii_email']   (+ persist_docs columns)
       keep customer_id only; resolve the email at read time behind the app's access rules
```
**Read the code:** Is the column a real subject's (a customer, a user, an employee) or a business contact? Does the project classify anywhere (a `+meta` default in `dbt_project.yml` by path, a policy-tag macro, a masking view) — read the effective value, not only the model's YAML? Does the copy need the raw value at all, or would the id do? **Finding when** personal data sits in an analytical copy with no classification the platform or the team acts on. **Proof:** V566 — every PII column in analytical copies carries a classification (two-ended: the column and the absent classification); a low-privilege role reads masked values.

### 2. Erasure does not reach the copy

```
BAD:   erase-account.handler.ts deletes from accounts and orders; snapshots/accounts_snapshot.sql keeps every version
       customer_ltv_daily keyed by customer_email, written by a cron, never named by the erasure handler
GOOD:  the erasure job's table list includes every copy (or the copy holds no identifier); snapshots get an erasure macro;
       stored exports are deleted or expire; a delete trigger or FK cascade removes derived rows
```
**Read the code:** Which tables does the erasure handler touch (2a), and through what — direct statements, an FK `on delete cascade`, a delete trigger on the source that removes or marks derived rows, a recompute that drops the subject's contribution? For dbt snapshots (2b): is there a scrub over the snapshot table, or does SCD2 history keep the subject forever? Delta tables: `DELETE` without `VACUUM` keeps the files (2d). Backups on rotation are precedent § 32. **Finding when** an erased subject's values remain in a derived table, snapshot, refreshed view, stored export or landing table. The OLTP erasure path itself is database `schema-integrity-scan` §10's. **Proof:** V565 — erase a seeded subject; no copy holds its values.

### 3. Pseudonymisation treated as anonymisation

```
BAD:   anon_email = sha256(lower(email))                       // shared with a partner as "anonymous"
       createHash('md5').update(user.email).digest('hex')      // reversible by a dictionary of common emails
GOOD:  createHmac('sha256', kms.key('pii-join')).update(email)  // keyed; the output is still personal data (precedent § 31)
```
**Read the code:** Is the hash keyed (HMAC with a secret from a KMS or a secret store) or a bare digest? Is the output shared, exported, labelled anonymous, or used only as an internal join key (precedent § 33)? **Finding when** an unkeyed hash of an identifier leaves the controlled environment or is treated as non-personal. **Proof:** V567 — hash 10k common emails with the repo's function: zero join hits against the shared column.

### 4. Production data copied to a lower environment

**Read the code:** Does a script, Makefile target or CI job restore a production dump into staging, dev, QA or a preview environment? Is there a masking step in the same pipeline (`pg_anonymizer`, a masking view, `--exclude-table-data` for the PII tables, a faker pass)? Synthetic seeds are not this class. **Finding when** production personal data reaches an environment with weaker access control. **Proof:** V567 — a sample of the lower environment holds no production identifier.

### 5. Stored or pushed exports with no retention or access rule

**Read the code:** Where does the export land — a bucket, a disk path, an email attachment, a third-party API? Does the bucket have a lifecycle rule, the job an expiry, the download a signed URL with a short life? Is the destination the controller's own (precedent § 34: a merchant pushing its own customers to its own sheet) or a third party's? Raw landing datasets: table or partition expiration? **Finding when** exported personal data is kept with no rule that ends it, or pushed to a destination nobody controls. **Proof:** V565 — after erasure and the retention window, the stored export no longer holds the subject.

### 6. Masking configured but not effective

**Read the code:** BigQuery: are `policy_tags` declared and `persist_docs: {columns: true}` set for the same models (6a)? Snowflake: does each tag-based masking policy's input type match the type of every column the tag reaches (6b) — a `string` policy skips a `number` column? Databricks: is the column mask attached to the table the readers query, or to a staging copy? **Finding when** a column the team believes masked is readable in clear by a role that should see it masked. **Proof:** V566 — the low-privilege role reads masked values.

## Output

```
pii-copy-scan — lanes: dbt 1.9 (dbt-duckdb; bigquery in prod profile); app: postgres 17 migrations; iac: terraform (aws)
Surface: 0b 2 tables with PII columns · 0c 2 erasure handlers · 2b 2 snapshots · 0d 9 export/dump files

Findings: 2 · Unconfirmed: 2 · Enabler: 1

1. analytics/snapshots/accounts_snapshot.sql:1                     [report-with-fix]   high
   §2 PC-2 — SCD2 snapshot of raw.accounts (email, phone, full_name); the erasure handler
   (apps/reporting/src/privacy/erase-account.handler.ts:14) names accounts and orders only. V565 on duckdb:
   the erased subject's email remains in 3 snapshot versions. Fix: an erasure macro over the snapshot + hard_deletes.
2. apps/reporting/migrations/Migration20260410.ts:12               [report-with-fix]   high
   §2 PC-2 — customer_ltv_daily keeps customer_email; no handler, cascade or trigger reaches it. Two-ended.
   Fix: key by customer_id and resolve the email at read time; add the table to the erasure job until then.
3. apps/reporting/src/exports/affiliate-feed.ts:33                 [report-flagged]    medium
   §3 PC-3 — sha256(lower(email)) as anon_id in a partner feed. Unproven link: whether the partner can join on it.
   Settling: V567.
4. infra/object-store.tf:4                                         [report-flagged]    medium
   §5 PC-5 — aws_s3_bucket "exports" with no lifecycle configuration. Unproven link: whether a cleanup job deletes files.
5. (policy)                                                        Enabler             —
   No retention period is declared for exported files (UNDECLARED) — /data-fix asks before writing one.

Routed: 1 → security data-exposure-scan §6 (email in a log line) · 1 → documentation doc-drift-scan (a manifest sentence)
Verification: 14 candidates → 2 findings · 2 unconfirmed · 1 enabler · 3 hardening · 6 refuted (self-verified; precedents § 31, § 33, § 34)

Class coverage:
  §1 classification      checked (1a 2 files + 1b 11 → hardening: internal marts, effective +meta from dbt_project.yml)
  §2 erasure reach       checked (2a 4 tables · 2b 2 snapshots · 2c 2 → 2 findings)
  §3 pseudonymisation    checked (3 2 → 1 unconfirmed; 1 keyed HMAC refuted, precedent § 31)
  §4 lower environments  checked (4 1 → 0; the restore runs through pg_anonymizer)
  §5 stored exports      checked (5a 1 + 5b 1 + 5c 1 → 1 unconfirmed; the sheet push is the merchant's own data, § 34)
  §6 masking effective   N/A (no policy tags or masking policies)
```

`[report-with-fix]`: erase-and-scan or the dictionary oracle failed on a scratch target, or the fact is two-ended (the erasure list and the copy; the column and the absent classification), and the fix is local to the model, the handler, the hash call or the pipeline. `[report-flagged]`: one link is unproven (a cleanup job outside the repo, the partner's ability to join, the effective masking in the warehouse); the row names it and the settling oracle. The coverage block lists **all six** §s on every run: `checked (k → m)`, `N/A (<reason>)`, `blocked (<grep>: rg exit 2)`, `→ <owner> (k)` or `Live, unreviewed (k)`.

## False positives / precedents

The verifier reads [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:
- § 31 a keyed HMAC with the key in a KMS; § 32 erased subjects in rotating backups never restored without re-applying erasures; § 33 a hash used only as an internal join key; § 34 the subject's own access response, or the controller exporting its own data to its own destination.
- Synthetic seeds and a masked restore (not PC-4); a business contact address (not a subject's personal data); an aggregate with no identifier.

## When to run

- On any diff that adds a column to a model, snapshot, rollup or export mapper; touches an erasure handler; adds a dump/restore script, a bucket or a third-party push — `/audit-data-quality`'s analytical-PII signal.
- On every whole-repo `/audit-data-quality`, reporting all six §s; before a dataset is shared outside the team.

## Halt conditions

- **Do not report a high row as confirmed until a separate verifier has refuted it.** That verifier is not the run that found it, gets the copy, the column, the erasure list and the classification read with their `file:line` — never this run's reasoning — and passes the row through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) §1–§8 with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md). Under `/audit-data-quality` that is `@data-quality-auditor` in refute mode, then `@verifier` mode `refute` on every BLOCKER and HIGH; standalone, apply the protocol as a second pass and tag the rows `self-verified`.
- **Never print, copy or sample a personal value.** Probes report counts and column names; V565 seeds a synthetic subject; V567 hashes a public dictionary, never production values.
- **Never run an erasure, a restore or a build against a shared or production target.** Oracles run on duckdb, a throwaway container or a target the user confirmed is not production; a probe that cannot be isolated is `NOT-RUN`.
- **This skill writes nothing into the audited repo.** `find <root> -newer "$OUT/EXP.txt"` is empty at the end; a fix is `/data-fix`'s, and a retention period or a legal basis is the user's decision.
- **No `[report-with-fix]` without the V-oracle failing** or a two-ended fact. **Do not grade a class another skill owns** (§ Boundaries). **Do not print "personal data looks protected"** without the coverage block.

## Related

- `/audit-data-quality` dispatches this skill; `@data-quality-auditor` judges PC rows; `/data-fix` closes them with V565–V567 failing first.
- This pack: `derived-data-scan` (the rollups 0b reads), `extract-scan` (the export files 0d reads), `incremental-load-scan` §4 (deletes that never reach a warehouse table), `lineage-trace` (which marts a PII column flows into).
- database `schema-integrity-scan` §10, security `data-exposure-scan` §6 and `@data-privacy-reviewer`, infrastructure `dr-audit`, product `tracking-plan-scan`, documentation `doc-drift-scan`, when those packs are installed.

## Sources

From the data-engineering research (2026-10-19): EDPB Guidelines 01/2025 on pseudonymisation (2025-01-16, consultation; final text UNVERIFIED there) and commentary on EDPS v SRB C-413/23 P; ICO right to erasure (OGL v3.0); Databricks GDPR with Delta (VACUUM, REORG … APPLY (PURGE)); Snowflake tag-based masking; Databricks row filters and column masks; dbt BigQuery configs (`policy_tags`, `persist_docs`) (DE-B-30…35). The real-repo shapes (a delete trigger closing a rollup's erasure, a merchant's own-data sheet push) are from the batch-8 eval § 3 (read-only, 2026-10-19). Counts are from read-only runs of the block above. Paraphrased; no text reproduced beyond identifiers.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.