Privacy centre
Paid add-on. Retention, search, export, erasure and an append-only trail, for the data your forms hold.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Answering a privacy request”.
What it does not claim
It does not make your site compliant, and it says so on its own screen.
It knows nothing of your other plugins, gives no legal advice, and does not delete WordPress accounts. Saying so matters: a “privacy centre” that let you believe it covered everything would make you miss what you think is done.
The address is matched exactly
A LIKE %camille@example.test% looks more helpful and does two opposite kinds of
damage. It catches camille@example.test.attacker.example, whose owner asked for
nothing — you would be exporting their data to somebody else. And it catches
not-camille@example.test, who is a different person.
On an export, getting the person wrong is a disclosure. On an erasure, it is a loss. Both are irreversible, and neither shows at the moment it happens.
Case is normalised, because nobody thinks Camille@Example.test is somebody
else, and demanding exact case would fail legitimate requests.
A term that is not an address searches nothing. Without that refusal, “camille” — or worse, a space — would sweep every value on the site.
Scope is in the query, not in the display
An operator who only manages their own forms must not be able to learn, from a count or an identifier, that a person answered elsewhere. The scope is computed once, on entry, and passed everywhere; no call recomputes it and none widens it.
An empty scope returns nothing. It especially does not return everything —
that is the classic fault of an IN () built by hand, and it would open the
whole site to somebody with no forms.
Each module declares what it holds
The centre does not sweep the tables. It could — they are all prefixed slf_ —
but it would have to know which column carries the address in each: twenty
copies of the same knowledge in the wrong place. A module added tomorrow would
be forgotten, and the omission would not show. An incomplete export looks
complete.
So each module declares itself, through solis_forms_pro_personal_data_providers.
One that does not declare appears neither in the export nor in the erasure — and
that is observable, because the registry is listed on the screen.
Erasing by submission already existed: delete() purges the attachments and
fires solis_forms_entry_deleting, which every module already subscribes to. The
centre hooks into that rather than redoing it. What it adds is erasing by
person — what a module holds with no submission attached: an SMS opt-out, a
CRM contact, a portal row.
“Erased” and “anonymised” do not say the same thing
Conflating them would cost dearly the day somebody asks for proof of what was done. An anonymised accounting entry stays in the books; a deleted submission does not.
A module that must keep a trace returns what it anonymised, and says what it retained and why. The centre does not decide in its place: it reports.
A partial failure does not undo the rest
One could want to roll everything back for a consistent state. That would be wrong here. A person asked for their data to be erased; a remote service being unreachable is no reason to keep the other ten copies.
Every module is called, its result recorded, and the request stays replayable for what failed. A module that throws — a third-party add-on, a bug — is recorded as a failure and does not carry the request with it.
Replaying is safe
Modules return “nothing to do” for what they have already handled. A second run picks up what failed without undoing what succeeded, and the trail keeps both passes — which is precisely what you want to be able to show.
Confirmation is a separate gesture
Opening a request and running it are two gestures, two nonces, two trail lines. They could be joined behind a JavaScript confirmation; that would put an irreversible erasure one click away on a screen you skim.
And confirming records who confirmed, which a dialog box cannot do.
When the request is run, the address is asked for again. It is not stored, and its fingerprint does not reverse. That is a deliberate constraint: the table does not carry what we are about to erase.
The requests table holds no address
Only a fingerprint, under the site’s salt.
A table of erasure requests carrying the addresses of those who asked for erasure would be exactly the file they wanted gone — and it would survive the erasure of their submissions. The label shown on screen is rebuilt from the submissions while they exist. After the run there is nothing left to show, and that is the point.
The trail cannot be taken back
Append-only, as the plan requires. No state, no updated_at, and no method to
update or delete a line — the constraint lives in the class rather than in a
database permission, which shared hosting does not grant.
A trail you can correct proves nothing. That is its only reason to exist: the day somebody asks what was done with their data, it is the only answer that counts, and it has to predate the question.
Deleting a request does not touch its trail. The two tables are bound by no constraint: deleting a request would carry away the proof that you did what you owed.
The export never lands in your uploads
That would have been simplest: write the ZIP into uploads/ and hand out the
address. It would be a file holding everything about a person, at a guessable
address, served by the web server, that nobody would think to delete. Privacy
requests would then produce the very leak they exist to repair.
The archive is built in the system’s temporary directory, streamed behind a capability, and deleted immediately.
Without ZipArchive — not guaranteed on every host — everything is served as one
CSV with an extra column naming the module. Refusing the export for that reason
would deprive you of an obligation you have to meet.
Retention is per form, and off by default
A purge active by default would erase, on the first nightly pass, answers nobody decided to lose — and on a site installed two years ago, all of them at once.
Below seven days nothing is applied. The floor leaves time to notice a 1 typed instead of 100, and that week costs nothing to somebody who really meant to purge. A delay below the floor means nothing, never “right away”: reading a typo as an order to erase immediately would be the worse of the two readings.
Anonymising keeps the counts
That is the half people forget. Emptying the whole submission would be simpler and would destroy what identifies nobody: the date, the form, the amount paid, the answer to “which service interests you”. A shop that anonymises its customers must not lose its turnover, and a survey must not lose its answers — that is what an anonymised survey is supposed to become.
Replacement is by field type, not by label. A “Your name” field may be called
nom, name, client, qui; guessing from the label would work nine times out
of ten, and the tenth would leave an identity in place with nobody noticing.
| Treatment | Types |
|---|---|
| Replaced by a token | email, name, phone, address, url, hidden, signature, file |
| Kept as they are | select, radio, checkbox, number, date, time, country, payment, calculated |
| Emptied | everything else, free text included |
Free text is emptied rather than tokenised: no rule can extract an identity from a comment, which often carries a name — sometimes somebody else’s. Keeping it would amount to anonymising nothing.
A type that is not classified is emptied. The omission goes towards erasure, which shows on reading, rather than towards keeping, which never shows.
The token is stable and specific to the site: two answers from the same person stay linkable without anybody knowing who, and the token means nothing on another installation.
The deadline is checked twice
The repository’s date filter silently discards a value it cannot read: a format mistake there produces no error, it produces “no filter”. On a purge that would mean erasing every submission of the form, with no exception to signal it.
The runner therefore re-checks each date itself, with a comparison that depends on no format — two strings we composed ourselves. The format fix alone would have been enough today; this check holds the day somebody changes what the query accepts.
The pass is bounded
A site installed two years ago that switches on a thirty-day retention has, the next day, tens of thousands of submissions past their term. Handling them at once exceeds the execution time, dies halfway, and starts again the next day at the same place — forever.
What is left waits for tomorrow, and the backlog clears itself in a few days. A late erasure has no consequence; one that never finishes does.
What V1 does not do
No universal handling of every WordPress plugin, no legal advice, no deletion of a WordPress account without a dedicated module. The flow stops at the data your forms hold.
