DESIGN NOTES / SALES SCRIBE
A retention floor you can prove in four lines
How a single date constant in our data-retention rule turned out to fall short of three of the four regimes it was meant to satisfy, and why we fixed it with a cruder rule rather than a cleverer one.
Accounting software has to delete data. Customers close their accounts and expect their records to go. It also has to keep data: UK statute sets minimum retention periods, and destroying records early is a compliance failure rather than a bug.
So there is a floor. Nothing belonging to a closed or deleted organisation is destroyed until every statutory minimum has passed. Ours was one line of arithmetic: take the most recent record in the account, and keep everything until 1 January of that year plus seven.
Seven years, against rules that run to six, looked comfortable. It wasn't. It fell short for three of the four regimes we had to meet, and nobody had checked, because the margin looked obviously generous.
The statutory summaries below are our working understanding. They are not legal advice.
The situation
Four sets of rules apply, and what makes this harder than it first appears is that they don't all measure from the same starting point.
| Regime | Roughly | Measured from |
|---|---|---|
| Companies Act | 3 years private, 6 public | When the record was made |
| VAT | 6 years | The record |
| Corporation tax | 6 years | The end of the accounting period containing it |
| Self assessment | 5 years | The 31 January following the tax year |
Two of those anchor on the record itself. The other two anchor on a period the record falls inside — and that period can end up to a year after the record does.
That's where our rule broke. A record dated 1 April, in a business with a 31 March year end, sits at the very start of an accounting period that ends nearly twelve months later. The corporation-tax clock doesn't start until then. Our floor, counting from the record's own calendar year, ran out three months early. With a 30 December year end, it ran out 364 days early.
Self assessment failed differently and more quietly. Records dated between 6 April and 31 December need keeping until 31 January seven years later. Our floor expired on 1 January — thirty days short, for roughly three quarters of every year's records.
Only a 31 December year end was safe, which is exactly the case anyone reasoning casually about the rule would have pictured.
Two things made this worth taking seriously beyond the arithmetic. The failure mode is irreversible: there is no undo on a purge, and the affected records are precisely the ones a tax authority might later ask for. And the code path is close to unobservable — for a real customer account, the floor can't fall for several years, so this is software that will first do something consequential long after everyone has stopped thinking about it.
The options we considered
Leave it. The gaps run from thirty days to a year, on a rule that only fires for closed accounts. Tempting, and wrong: the point of a statutory floor is that it isn't approximately right.
Compute the real date. By this point we had everything needed to do it properly — a service that resolves the accounting period containing any record, and the organisation's legal form to tell a sole trader's regime from a company's. So we could calculate the actual latest retention date: for a company, the end of the period containing the youngest record plus six years; for a sole trader, the 31 January following its tax year plus five.
This is the correct answer in the narrow sense. It retains nothing longer than the law requires, which matters if you take data minimisation seriously, and it honours an architectural rule we had already written for ourselves.
Change one constant. Move the floor from 1 January to 31 December of the youngest record's year plus seven, and show that the result clears every regime in every case.
The decision
We changed the constant.
The reason is that the result can be proved in four lines. Take any record dated in calendar year Y:
- Corporation tax. The accounting period containing it ends at most twelve months later, so before 1 January Y+2. Six years on is before 1 January Y+8. The worst case, a 30 December year end, needs 30 December Y+7.
- Self assessment. A record dated 6 April to 31 December falls in tax year Y/Y+1, so five years after the following 31 January is 31 January Y+7. Earlier records in the year need less.
- VAT. At most 31 December Y+6.
- Companies Act. At most six years from the record.
31 December Y+7 clears all four, with room.
That proof is now the type's documentation. It is also a test: for each regime we compute the true retention date independently from the record's date, on each boundary day — 1 January, 5 and 6 April, 30 and 31 December, a leap day — and assert the floor sits beyond it. The corporation-tax case is checked against every possible year-end day rather than against the worst case we picked by hand, because picking a worst case by inspection is how the original rule went wrong in the first place.
The precise calculation would have made the purge depend on three things that can each drift: period resolution, legal form, and tax-year arithmetic. Every one of those is a live part of the system that will change. A crude rule you can prove in four lines is worth more than a precise one you have to keep true, when the cost of being wrong is destroying records that can't be recovered.
We also added something the old rule had no answer for at all. Both tax regimes extend retention while an enquiry is open, and no arithmetic can know that. There's now a hold — a date and a reason on the account — that stops the purge regardless of what the floor says.
The trade-off we accepted
Up to eleven months of over-retention. A record dated in January is kept nearly a year longer than the law demands. Against a purge that only fires for closed or deleted accounts, that's a price worth paying — and if data-minimisation pressure ever makes it matter, the precise calculation is a refinement we can add, with the groundwork now in place.
The hold has no interface. It's a database field and a runbook entry, not a workflow. That's the right size for something that has never yet been needed, and it will want building properly the first time it is.
It's less elegant. Someone reading the code sees a constant where they might expect a calculation. The doc comment is there to stop them improving it.
What we learned
A margin that looks obviously generous is the one nobody checks. Seven years against rules running to six reads as comfortable, so the arithmetic went undone. And every gap came from the two regimes that measure from a period rather than from the record — a difference of kind, invisible to a rough comparison of numbers.
Match the precision of a rule to the reversibility of getting it wrong. Elegance is worth a lot in code you can fix on Tuesday. It's worth much less in a path that deletes data permanently, runs years after it was written, and has nobody watching. Prefer the version whose correctness you can still demonstrate when you've forgotten how it works.
Enumerate the cases rather than reasoning about the worst one. The original rule was wrong because someone pictured a typical year end. The replacement is tested against every year end there is, and that difference cost about ten lines.
Write constraints down as tests, not as intentions. Alongside this we pinned a rule about which code is allowed to bypass a particular safety check. Writing the pin immediately turned up a second caller nobody had accounted for. It was harmless — but it was found by stating the constraint, and it would not have been found any other way.