Applies to
legal.consent-record, legal.consent-retention.
Covers legal.consent-record, legal.consent-retention. Related: legal.consent-record-context, legal.terms-consent-checkbox.
Article 7(1) of the GDPR asks for one thing and names no fields: where processing rests on consent, the controller has to be able to demonstrate that the person consented. Everything these two rules store beyond that is this standard's own answer, and its only test is whether it survives somebody asking about one submission eighteen months later.
What the article says, and what is ours
The whole duty behind legal.consent-record, quoted from the consolidated text:
Where processing is based on consent, the controller shall be able to demonstrate that the data subject has consented to processing of his or her personal data.
No field, no format, not even a record. So most of what the two rules ask for is ours:
| What the rule asks for | Where it comes from |
|---|---|
| Being able to demonstrate a consent | Art. 7(1), the duty itself |
| The wording was intelligible, clear and plain | Art. 7(2) supports this half |
| Who, when, the exact wording, its version, read from the render function, backed up | Ours |
| The period told to the person | Art. 13(2)(a), verbatim |
| Deleted once it is no longer needed | Art. 5(1)(e), verbatim |
| The job that deletes, and the export | Ours. 5(1)(e) wants the outcome, not a cron entry |
legal.consent-retention carries the soundest citation in this section, both halves black letter;
legal.consent-record is one sentence of duty with four fields on top, plus three more in
legal.consent-record-context. That is the norm here. 99 of this standard's 100 citations are
partial, and partial is only honest while it is labelled.
Scope: this log is form consent, not the banner, which is article 82 and CNIL and is graded by
legal.cookie-banner. And the checklist asks it for a status, accepted or refused, while the
shipped component writes a row only per accepted consent. Decide that on day one: rows nobody
wrote cannot be backfilled.
The wording has to come from where the label renders from
The point of storing the sentence is that it is the sentence the person saw, and two things break
that. Wording taken from the request body proves nothing, a posted text field being
forgeable. Resolve it server-side inside the writer, as both delivered builds measured against
this standard do. And one source beats two kept in step - both of those builds render the
label from a second literal, held in agreement with the logged one by a comment.
// One resolver. The template calls it, the writer calls it, nothing else knows the text.
function wps_consent_text( string $key ): array {
$all = [ 'terms' => [ 'version' => '2026-08-01', 'text' => 'I accept the privacy policy.' ] ];
return $all[ $key ];
}
$c = wps_consent_text( 'terms' ); // template
printf( '<label for="terms">%s</label>', esc_html( $c['text'] ) );
$c = wps_consent_text( 'terms' ); // writer, after server-side validation
wps_log_consent( [ 'user' => $email, 'purpose' => 'terms',
'text' => $c['text'], 'version' => $c['version'] ] );
The expensive part is not the old records, which keep the sentence they were written with. It is every record written between the edit and the day somebody notices: the label shows one sentence, the log another, and nothing in the row tells it from an honest one. So bump the version in the same commit as the sentence: two sentences sharing a version is worse than no version at all, because it makes the key look reliable.
The row is deletable, so the proof is the backup
The shipped log, wp-starter/consent-log, blocks create_posts and calls itself immutable, and
that block is all it is: an administrator can still delete a record and edit its title, and nothing
hashes or chains the rows. In a dispute you rely on a dated dump off the post table
(ops.db-write-backup), and on that table being unreachable without logging in
(security.submission-store-private).
Retention is two halves, and one of them is not yours to decide
Publish the period, then delete past it. A published period nobody enforces satisfies neither
clause, and neither does a purge job on a period nobody published. The number is a legal question,
not a technical one, which is why the rule is owned by the client: ask them or their DPO, get it
in writing, publish it (legal.documents-published).
The period lives in content, not in code, as a paragraph on a page in the database. Both delivered builds publish one, one of them stating three years for contact data, and one ships a seed whose policy is lorem ipsum, so a fresh install of correct code states nothing. Read the rendered page, not the repository.
Nothing runs the job. Neither delivered build has a scheduled purge and neither does the
starter theme, so this leg is aspirational everywhere, and WP-Cron fires only when somebody
visits, so the date is a suggestion until a real cron entry exists (ops.cron-runs). Ours rather
than the regulation's: keep the record at least as long as you use the data it authorised, or
purging the proof first leaves you a mailing list with no demonstrable basis.
The export leg has no clause behind it
The citation ledger says so in as many words: the exportable-log half of legal.consent-retention
is uncited, Art. 15 and 20 territory. Read this section as our position, not the regulation's.
An access request asks what you hold about a person, so the answer is everything stored, and the ordinary failure is not a refusal but an export that looks complete. WordPress ships the framework, somebody registers a callback naming the fields they remember, and later fields then reach the writer and never the exporter. Measured: the starter theme exports 8 of the 12 keys its own writer stores, missing the IP, the language, the method and the source URL, and the two delivered builds export 5 of 12 and 7 of 11. Count the export against the writer, never against the exporter.
One of those four keys should not be in the record at all: this standard declines the IP address
the checklist asks for, because it identifies nobody reliably behind carrier NAT, a VPN or a CDN
and creates a minimisation obligation rather than discharging one. The starter theme writes
REMOTE_ADDR anyway and never exports it, the one combination with nothing to be said for it. And
erasure pulls the other way, the record being personal data too, so granting it destroys the
proof: the starter theme anonymises the consent row while deleting the subscriber row, so the
wording and the timestamp survive and nobody who asked to be forgotten stays on a list.