How long we keep data
Data retention policy (APPROVED)
Amended 2026-10-12 (869ef84kn): "the big one" is now enforced in code, and the measurement that came with it says the estate had been breaching this period since the week it started collecting.
The 24-month applicant row, the agent rows that match it and the ApplicationAgent copy are enforced by [manage.py purge_applicant_data](../../platform/applications/management/commands/purge_applicant_data.py), whose logic is [platform/applications/retention.py](../../platform/applications/retention.py) and whose collector half is kestrel.storage .purge_applicant_personal_data. It anonymises and never deletes a planning record: the name and the applicant's own address are cleared, the agent copy is deleted, the site address is reduced to the postcode the council published in it, the scraped raw blob is scrubbed of the same keys, and the reference, proposal, dates, decision and council are untouched — which is exactly what the first row of the table below keeps indefinitely.
This period was never theoretical, and the ticket that raised it thought it was. The clock runs from the decision date, not the collection date, and councils publish historic decisions — so a page read this morning can deliver a 2004 decision that is past its retention the moment it lands. Read-only on production, 12 Oct 2026: 2,614 rows across 20 councils were already past 24 months and still held applicant or agent personal data, against a store whose oldest collection is 19 August 2026.
Two rows of this table disagreed about one table, and the longer period won. letters.Letter carries no copy of who was written to — it is a foreign key to the application, and the addressee is read back through it — so anonymising at 24 months would destroy at 24 months the letter send record this document keeps for 6 years. An application with a real posted letter against it is therefore held until the letter is six years old, and the holding is counted in every report rather than being silent. On production 0 of the due rows had one, so the rule costs nothing today. Dan ratified that choice on 2026-10-12 (869ef84kn): "correct — hold the applicant data for 6 years where a letter was posted". So the six-year hold is now an approved reading of this document rather than a run's judgement, and the 24-month row is read as "24 months, unless a letter send record this policy keeps for six years would be destroyed with it".
The first live purge HAS now been run, on Dan's authorisation and bounded to 100 rows (869ef84kn, 2026-10-12, same ticket). Both stores, one pass: 100 platform rows (99 names, 73 applicant addresses, 24 site addresses reduced to their postcode, 76 emptied, 0 agent copies — there were none inside the first hundred) and 100 collector rows (99 applicant names, 72 applicant addresses, 72 agent names, 14 agent firms, 58 agent addresses, 99 raw blobs scrubbed) across 5 councils. Nothing was deleted: both stores hold exactly the rows they did, and the reference, proposal, dates, decision, status and URL of every purged row are byte-identical to a backup taken minutes earlier. What remains due fell from 2,651 to 2,551 in both stores, which is the bound exactly and is the pass's own proof of idempotence. The rest of the backlog is not yet purged and needs a further press.
The operator sequence is written down: [docs/gdpr/APPLICANT-PURGE-RUNBOOK.md](APPLICANT-PURGE-RUNBOOK.md) (added 2026-10-14, 869ef84kn) — the figures to read first, the verified backup set, the live pass, the proof, the rollback, and the four sudo lines that install the timer. It exists because every step after the first is Dan's own, and a sequence held only in a ticket comment is not a procedure.
The .service and .timer are written and are still not installed and not enabled: a live pass needs --commit and KESTREL_ALLOW_LIVE_PURGE, and each one is Dan's to press, with --limit and a reading of the counts. The period is KESTREL_APPLICANT_RETENTION_MONTHS, which may be lowered without a code change and never raised past the 24 published here.
What this does NOT enforce, and which is still the manual intention the banners below describe: the 90-day server/application log period (log files on the droplet, not a database table, and nothing in this change touches journald or nginx), and the four 6-year audit periods (letter records, ExportEvent, ExportDelivery, send audit and impersonation records) — none of which can be due before 2032, and each of which needs its own job written nearer the time. The event-based rows (waiting list, "until you ask us") are events, not dates, and are not a sweep's work.
Amended 2026-08-29 (869enj3v8): one row added, describing a table that already existed. The free postcode preview shipped the same day (869endv2t), and tracing what a signed-out visitor's search actually does found that it writes a PostcodePoint row when the postcode is new to us — a place, not a person, and kept for good. The customer privacy notice now discloses it, so it is here too: the two are one timetable and stay one. This row documents existing behaviour rather than setting a new period, and Dan can overrule it; nothing else in this document changed.
Amended 2026-08-29 (869eqy933), on Dan's answers to two of the eight open questions on the GDPR pack. Two timetables became one.
- Q5 — the eight periods the customer privacy notice invented are approved as drafted and folded in here, which is the option he picked over "approve them but leave them in the notice only". They are the rows marked (customer record, approved 2026-08-29) below. The notice had carried them since 2026-08-24 marked "new in this draft, awaiting the same approval" while this document — approved on 2026-08-07 — did not mention them, so the published notice and the published policy stated different timetables for the same company. Nothing was changed on its way in: the periods below are the periods the notice named.
- Q9 §11.4 — the agent rows are approved at 24 months, so the section headed "proposed, not yet approved" is gone and its four rows are in the table. That closes condition C5 of
docs/gdpr/LIA-AGENT-ACQUISITION.md§10, which had been open waiting for exactly this answer.
The enforcement caveat below is unchanged and still applies to every row: the timetable is a published intention, not a scheduled job (869ef84kn).
Amended 2026-08-29 (869eqv08d): the officers' row is the first one in this table with an enforcement mechanism, because it is the first one that was measured and found untrue. "Not collected, nil retention" was true of the code from 27 August and never true of the server: production ran v0.447.0, the guard shipped in v0.449.0, and two days later the store held 8,331 officers' names again, including rows written that morning. It was purged, and a version-independent sweep ([deploy/officer_guard.py](../../deploy/officer_guard.py), run every fifteen minutes by deploy/kestrel-officer-guard.timer) now holds the nil retention on the server whatever version the server is running. The caveat below — the timetable is a published intention, not a scheduled job — therefore no longer applies to this one row, and still applies to every other.
Amended 2026-08-27 (869eqv08d), after approval and on Dan's own ruling: one row added to the approved table below — council officers' personal data, not collected, nil retention. Nothing else in this document changed, the approval below stands, and the amendment is noted here rather than made silently because the table is Dan's and was settled on 2026-08-07.
Renamed 2026-08-20 (ticket 869em1n5x), and the contact address followed on 2026-08-27 (869ee2apd): the trading name changed from Plan & Post to Just Granted, and the address to write to is now hello@justgranted.co.uk on Dan's instruction. The controller is unchanged — Blueworkz Ltd, company number 13258077. hello@planandpost.co.uk is the same mailbox and still receives, so a letter or an objection sent to the old address is not lost.
Status: APPROVED by Dan, 2026-08-07 (ticket D6). The periods below are settled. Enforcement is still manual — no code prunes data on this timetable yet. Automatic enforcement is ticketed separately (869ef84kn, on hold). Until that ships, this policy states an intention we are not yet meeting, which matters if anyone asks.
The "enforcement is still manual" line above is true of the 24-month applicant period and is no longer true of every row. Four periods are now enforced by code: the 7-day export file (manage.py send_scheduled_exports), the 30-day soft-deleted template (manage.py purge_deleted_templates), the nil retention on council officers' names (deploy/officer_guard.py, every 15 minutes) and, from 2026-10-07, the 90-day Google click id (manage.py purge_click_ids, daily). The rule this estate now applies to anything new: a retention line and the job that enforces it ship in the same commit, because a period nobody enforces is a documented promise we break. 869ef84kn still owns the big one.
The click-id row (2026-10-07, 869f73zda) is written by a run and is awaiting Dan's ratification on that ticket. The period is already enforced in code, so the risk of it being wrong is that it is too long or too short rather than that it is unmet.
Re-confirmed by Dan 2026-08-11: 24 months is final and not re-opened. The boundary between tickets, so it is not built twice: D6 (869ee2apd) owns this policy text and the suppression list; 869ef84kn owns the scheduled purge that enforces the timetable — including the rule that the purge must never delete a suppression entry, and the dry-run-before-live requirement recorded on that ticket.
Operator: Blueworkz Ltd trading as Just Granted. Registered office: 39 Crispin Field, Pitstone, Leighton Buzzard, LU7 9BG.
| Data | Period | Rationale |
|---|---|---|
| Planning application records without personal data (reference, proposal, dates, decisions, council) | Indefinite | Not personal data once the applicant name/address association is removed; historical planning data has analytical value. |
| Applicant personal data (name, site address as an identifier of a person) | 24 months from the decision date (or from last collection where undecided) | Long enough to serve the trades' sales cycle (approvals are actioned within months); short enough to be defensible as necessity-bounded. |
| Suppression list entries | Indefinite | The entry exists to honour an objection; deleting it would restart unwanted contact. Contains the minimum data needed to match. |
| Letter send records (who was written to, when, by which client, which template) | 6 years | Evidence of what was sent (complaints, disputes, accounting). |
Export audit log (ExportEvent) | 6 years | Evidence of personal-data egress (UK GDPR accountability). |
Scheduled export files awaiting collection (platform/exports/, one per delivery) | 7 days from creation, then deleted | The file exists only to be fetched once. A weekly delivery means the next file replaces it before the last expires, so a customer never needs two. Deleted by manage.py send_scheduled_exports, which runs daily for this reason and not only on send days — a weekly sweep would let a file outlive its own link by up to six days. settings.EXPORT_DELIVERY_TTL_DAYS is the single source of the period, and governs the download signature's max_age as well as the deletion. |
Scheduled delivery records (ExportDelivery) | 6 years | The same accountability answer as ExportEvent, for the disclosures nobody pressed a button to make: what was built, how many rows and bytes it held, whether it was emailed, and whether it was ever collected. Kept after the file is deleted — purged_at records that the bytes are gone, which is a fact worth being able to evidence. |
| Council planning officers' names, direct emails and phone numbers | Not collected. Nil retention. | Dan's ruling of 2026-08-27 (869eqv08d): the field had no purpose and no notice, so it was deleted rather than assessed — 64,712 names across 273 councils and 151 direct emails and phone numbers, erased from production the same night. Nothing writes one now (kestrel/storage.py, OFFICER_FIELDS). A period is recorded as nil rather than omitted, so the absence is deliberate and auditable. Enforced on the server every 15 minutes since 2026-08-29 by deploy/officer_guard.py + deploy/kestrel-officer-guard.timer, which is version-independent — the first purge held for 34 hours because the code enforcing it was never deployed and 8,331 names came back. Does not cover inbound_emails.sender, the address a council writes to us from, which is how a bulletin is matched to a council and follows the log row above. |
| Client account and billing data | Life of account + 6 years | Tax and contract records (statutory). |
| Server/application logs containing IPs | 90 days | Operational security only. |
| Send audit and spend-alert records (customer record, approved 2026-08-29) | 6 years | The same period as the letters they account for. An audit of a send is worth nothing if it expires before the send record does. |
| Staff impersonation records (customer record, approved 2026-08-29) | 6 years | Matching the other audit records. An audit trail deleted before the thing it audits is not an audit trail. |
| Email consent and its history (customer record, approved 2026-08-29) | Life of the account + 6 years | The evidence of a consent has to outlive the send it authorised, or we cannot show the send was authorised. |
| Funnel milestones and acquisition source (customer record, approved 2026-08-29) | Deleted with the account | They carry no personal identifier in themselves, so there is nothing to keep once the account they hang off is gone. |
| Waiting-list entries (customer record, approved 2026-08-29) | Until we open and have written to you, or until you ask us to delete them — whichever is sooner | The entry exists to be acted on once. A date would be arbitrary; the event is not. |
| Preview rate-limit hashes (customer record, approved 2026-08-29) | 2 days | An anti-abuse counter, and not reversible into an address in any case. |
Postcode coordinates in the map lookup table (PostcodePoint; customer record, added 2026-08-29, 869enj3v8) | Indefinite | A postcode, its two coordinates and the date we fetched them, with nothing recording who asked. A row is created either by the collector geocoding an application's postcode or by a signed-out visitor typing one into the free preview, and the two are indistinguishable afterwards — which is the point. A postcode's coordinates never move, so a period would only mean asking a free public service (postcodes.io) the same unchanging question again. Documented here because the customer privacy notice now discloses it and the two must be one timetable, not two. |
| Agent name, firm and business address in the collector store (approved 2026-08-29) | 24 months from last appearance on a collected application, then cleared from the row | Matches the applicant period. The planning record survives without it. |
The agent addressee copy in the platform database (applications.ApplicationAgent, added 2026-09-09, 869ez8ak3) | 24 months, inherited — it is a copy of the row above and holds nothing the row above does not | It exists because the send path has no access to the collector store. It is deleted with its application, and re-derived by every sync, so clearing the collector's agent columns removes this copy at the next run without a second job to remember. Empty on every environment until 10 September 2026: the sync wrote it only when kestrel.agentletters.may_address_agents() was true, and that was False while docs/gdpr/LIA-SUBSCRIBER-TO-AGENT.md was an unapproved draft. Dan approved it on 869eznx22, so the gate is open and the next sync on each environment starts writing these rows — which is why the 24-month rule above stops being theoretical. |
| The agent prospect list itself (approved 2026-08-29) | Not persisted. Derived at batch time and discarded once the batch is composed | A standing marketing list is a second copy with its own retention, its own leak surface and its own accuracy problem. Deriving it each time means an agent removed from the store leaves the channel automatically. |
| Record that we wrote to a premises, and when (approved 2026-08-29) | 6 years | The frequency cap and the audit trail both need it, and it is the same period as subscriber letter records. It holds an address and a date, not a profile. |
Google click id (Company.acquisition_click_id, its kind and the UTC instant of the click; added 2026-10-07, 869f73zda) | 90 days from the click, then the three columns are set to null | A gclid / wbraid / gbraid is Google's own identifier for one person's one click, so it is personal data, and it is kept for exactly one purpose: to upload the paid conversion that click produced, so the advertising optimises for customers who pay rather than for free signups. The 90 days is Google's number, not ours. Google's offline-conversion-import documentation states that it retains the GCLID "for only 90 days" and that a conversion may be uploaded "so long as it's no longer than 90 days" from the click, so on day 91 the value cannot serve its purpose at all and storage limitation requires it to go. Nothing shorter is possible without breaking the feature — a sale can take weeks — and nothing longer is defensible. Enforced, not merely stated, by manage.py purge_click_ids on a daily Persistent=true timer (deploy/kestrel-click-id-purge.timer) from the same commit that added the column; KESTREL_CLICK_ID_RETENTION_DAYS is the single source of the period and may be lowered without a code change. acquisition_conversion_uploaded_at is deliberately not cleared: it is a date with no identifier in it, and it evidences that an upload happened after the thing uploaded is gone — the same answer as ExportDelivery.purged_at. The value never appears in the Google Ads JSONL audit log, which that purge cannot reach: the log records a one-way 12-hex SHA-256 fingerprint instead. |
| Suppression entries about an agent's office (approved 2026-08-29) | Indefinite | Deleting one restarts the post the person asked us to stop. The same rule as every other suppression entry; the list has carried a role since 2026-08-24 so an agent's objection stops professional post without touching the households in that postcode. |
Data we license to somebody else
A copy of a bulk data file (869eqx0uk, priced 2026-08-27; none sold) leaves our control, so our retention timetable cannot reach it. What governs it instead is the licence, and the licence must say: replace the file weekly, delete the superseded copy, delete everything on termination. That obligation is condition C4 of §12.6 of the legitimate interests assessment, and it exists because the periods above are worth nothing against a copy nobody is required to delete.
The file we produce is subject to the same suppression check as everything else: an objector is removed before it is cut, and the cutter refuses to run without an up-to-date objection list.
Agent data — approved 2026-08-29, and the letters still have not started
The four agent rows moved into the table above on 2026-08-29, when Dan answered §11.4 of docs/gdpr/LIA-AGENT-ACQUISITION.md with "as assumed" (869eqy933 Q9). Until then they sat in their own section headed "proposed, not yet approved", because the table above is a document he approved on 2026-08-07 and adding rows to it quietly would have made his approval mean less than it says.
They cover the name, firm and business address of the planning agents, architects and consultants named on the applications we collect — professionals, at business addresses, published on the same registers. No letter has ever been posted to one, and none may be until the remaining conditions in that assessment are met (C3, the frequency cap, is the open one) and Dan has approved the batch. Approving a retention period is not approving a mailing.
Same standing caveat as every other row: none of this is enforced by code (869ef84kn). Adding a second dataset to an unenforced timetable does not make the timetable worse, but it does make that ticket more overdue.
One period is enforced by code. The seven-day life of a scheduled export file is deleted by applications.scheduled_exports.purge_expired on every daily run of manage.py send_scheduled_exports (deploy/kestrel-scheduled-exports.timer, 869enex13), and tests/unit/test_scheduled_exports.py asserts both that an expired file is destroyed and that a live one is not. Everything else in the table above is still the manual intention the banner describes.
Deletion is destruction, not archival. Backups: purged data leaves backups as they rotate (currently 14 nightly / 8 weekly — see docs/BACKUP-RESTORE.md), so full disappearance lags live deletion by up to ~8 weeks; this is documented in the privacy notice's spirit of "deleted on a published timetable".