ADR-0097: Anonymized prod-subset backfill for stage

Status: Accepted (2026-07-16) Owner: Gera Amends: the CLAUDE.md §"Stage Fixtures" policy ("stage is intentionally NOT a prod snapshot")

Context

propflow-stage is a curated no-PII bench (seeded by npm run seed:stage). That keeps it SOC2-clean but nearly empty: testing anything that needs realistic cross-channel data — an SMS thread with a related email, a work order with a real-shaped timeline, a renewal mid-flight — fails because the data simply isn't there. The May-2026 attempt to fix this (backfill-stage-from-prod.sh + anonymize-stage-pii.ts) had two flaws:

  1. Raw PII landed on stage first, scrub came after. The clone was a full unfiltered table copy; the scrub was light-touch (names/contacts on 3 hardcoded property partitions) and never touched message bodies, agent traces, tenantName caches, or email records. Worse, the scrub's own BACKFILL_REVIEW# audit rows stored the real before-values on stage.
  2. Not repeatable. No time-windowing, no overlap protection, no census of what was copied.

Decision

Stage MAY hold anonymized subsets of prod data, produced exclusively by the pipeline in scripts/anon-backfill/. The prior blanket rule ("stage is never a prod snapshot") is amended to: stage never holds prod PII; it may hold prod-shaped rows whose identifiers have been irreversibly rewritten by this pipeline, plus the curated synthetic bench.

The pipeline (5 stages plus an as-needed prune sub-step, each a separate dry-run-first CLI)

Stage Script Table Access
1. Export export.ts propflow-prod READ-ONLY scan → local NDJSON + key manifest + classification census
2. Transform transform.ts — (offline) select + anonymize + self-verify
3. Wipe wipe-prod-remnants.ts propflow-stage delete prod-origin rows (exact key match vs the prod key-set) + May-scrub audit rows
4. Load load.ts propflow-stage batch-write anonymized rows + day manifest
4b. Prune prune-superseded-run-rows.ts propflow-stage AS NEEDED: after a transform fix + load --force, delete this run's rows whose keys the new transform no longer produces (the sweep rewrites keys, so a superseded load can leave stale-key orphans no overwrite reaches)
5. Verify scan-stage-pii.ts propflow-stage READ-ONLY: assert zero real identifiers

Key properties:

Accepted exceptions (documented residual risk)

Runbook (repeat for any window)

cd <worktree>
# 1. Export prod (read-only). Review the census before continuing.
npx tsx scripts/anon-backfill/export.ts --start 2026-06-16 --end 2026-07-16
# 2. Transform offline. Fails loudly if any identifier survives its own sweep.
npx tsx scripts/anon-backfill/transform.ts --start 2026-06-16 --end 2026-07-16
# 3. Wipe prod remnants off stage (dry-run first, review wipe-report.json).
npx tsx scripts/anon-backfill/wipe-prod-remnants.ts --start 2026-06-16 --end 2026-07-16
npx tsx scripts/anon-backfill/wipe-prod-remnants.ts --start 2026-06-16 --end 2026-07-16 --write
# 4. Load stage (dry-run first).
npx tsx scripts/anon-backfill/load.ts --start 2026-06-16 --end 2026-07-16
npx tsx scripts/anon-backfill/load.ts --start 2026-06-16 --end 2026-07-16 --write
# 4b. ONLY if you re-ran transform after a fix and re-loaded with --force:
#     prune the prior load's stale-key orphans (dry-run first, review
#     prune-report.json, then --write).
npx tsx scripts/anon-backfill/prune-superseded-run-rows.ts --start 2026-06-16 --end 2026-07-16
# 5. Verify: zero real identifiers on stage (exit 1 on any hit).
npx tsx scripts/anon-backfill/scan-stage-pii.ts --start 2026-06-16 --end 2026-07-16
# 6. Delete the local run dir — it holds the raw export + dictionary (real PII).
rm -rf ~/code/tmp/anon-backfill/anon-2026-06-16-to-2026-07-16

Artifacts live in ~/code/tmp/anon-backfill/<runId>/ (chmod 700). The raw export and dictionary are PII — local to the operator's machine, deleted after verification, never committed, never uploaded.

Alternatives considered

Consequences