Applies to env.no-production-data-off-production.

Covers env.no-production-data-off-production. Related: env.staging-sends-nothing-live, security.submission-store-private, legal.consent-retention.

A database pull is a copy of everyone who ever filled in a form on the site, and the moment it lands on a laptop or a staging box it sits outside every deletion job, retention promise and access control the live site has. That is why this one blocks, and why the fix has to live inside the pull rather than in a step someone remembers.

What the source says, and what is ours

This rule used to cite GDPR Article 32. Article 32 was read in full during the citation audit and never mentions test environments, staging or synthetic data. It says:

shall implement appropriate technical and organisational measures to ensure a level of security appropriate to the risk

A risk-based duty, naming no measure anything can pass or fail. The sentence that actually carries the rule is a CNIL fiche, and it says it in one line:

Effectuer les développements informatiques et les tests dans un environnement informatique distinct de celui de la production [...] et sur des données fictives ou anonymisées.

CNIL is France's data protection regulator, and a fiche is guidance rather than black-letter law.

CNIL asks for A separate environment, and fictitious or anonymised data in it
The rule adds Named tables, a zero row count, the scrub inside the committed sync script
Nobody but us asks for The blocker severity

Why the copy is the exposure

Retention does not reach it. legal.consent-retention makes the client state a period and makes you build the job that deletes past it. That job runs on production, so a gzip in a project folder is a second copy nothing points at: someone who asks to be erased is erased from the site and stays in every dump taken since.

It is not one copy. Both reference builds pull with a bare mysqldump | gzip; grepping db-sync.mjs for ignore-table, anonymi, synthes, scrub or redact returns zero in both. The data really travelled: laptop row counts matched production exactly, and the consent rows carry an email and an IP, a field this standard elsewhere declines to log at all. One project's backups folder held 275 unpruned plaintext gzips going back four months, the other 217. Verdict: aspirational. Neither delivered build does it.

Disk encryption protects a laptop that is off. Not one asleep in a bag, not the copy a cloud drive synced, not the one a backup tool picked up. And the client is the controller, so it is their notification to make, about a machine they do not own and cannot search.

Doing it on a WordPress database

--ignore-table removes the table, not the rows. It omits the CREATE TABLE too, so where the pull drops every local table and imports the dump whole - what both reference builds do - the table is then absent and the site fatals. Dump in two passes into one stream:

SKIP="wp_form_entries wp_consent_log"
IGNORE=""; for t in $SKIP; do IGNORE="$IGNORE --ignore-table=$DB.$t"; done

{ mysqldump "$DB" $IGNORE          # everything else, structure and data
  mysqldump --no-data "$DB" $SKIP  # the skipped tables, structure only
} | gzip > dump.sql.gz

Most submission data is not in its own table. A submission custom post type lives in wp_posts and wp_postmeta beside the pages, so no table-level exclusion can reach it. That is a delete after the import, in the same script:

DELETE p, pm FROM wp_posts p
  LEFT JOIN wp_postmeta pm ON pm.post_id = p.ID
  WHERE p.post_type IN ('submission', 'consent_log');

Check wp_comments and wp_commentmeta, which hold a name, an email and an IP per comment, and wp_options, where a form plugin often parks both its entries and its mail credentials. MySQL refuses a subquery on the table you are deleting from, so do wp_users with wp-cli after import.

Drop rather than synthesise unless a template needs rows to render at all. Synthetic rows are work to keep believable, and a fake address is still an address somebody can accidentally mail.

In the script, not in the runbook

Zero row counts prove today's state; the scrub living inside the committed sync script is what makes it true next time. A runbook step gets skipped the first time someone pulls at 23:00 to reproduce a bug, and that is precisely the pull that stays on the laptop for six months. Verify against production's counts on the running site, never against the dump.

ops.db-write-backup pulls the other way, so know about it before it surprises you: every write to a live database dumps the target first, which means a push to production manufactures exactly the artefact this page is about. Both rules stand. Prune that folder on a stated retention and keep it out of anything that syncs to a cloud drive.

Staging that writes is the same problem in reverse

Measured on both reference builds: the staging notification address is the client's own public inbox and no SMTP is configured, so wp_mail() falls through to the box's mail(). A form test on staging emails the client. Neither has a test inbox or any interception, and neither has a CRM, which is the only reason that half of env.staging-sends-nothing-live is not a finding as well.

Mail is recoverable and merely embarrassing. A CRM write is neither: a fake contact fires a sequence, someone calls it, and the record is already in a dataset that has been exported twice. Gate it in code, not in a database row - one of the two builds keeps its staging quiet only because a row says 0, and env.env-config-not-in-db exists because a content push overwrites that row.