An audit log with a delete button is a diary
Append-only is not a database feature you enable. It is a property you have to refuse to break, in every layer, every time somebody asks.
Every application we review has an audit log. Most of them are decorative.
The test is simple, and it is not “is there a log”. It is: can the person the log is about remove the entry? If yes, the log tells you what happened to people who were not paying attention. That is a diary, not an audit trail, and it will not survive the one conversation it exists for.
What breaks it
Rarely a delete button. It is almost always one of these, all of which look reasonable in isolation:
ON DELETE CASCADE. The audit rows reference a user. The user is deleted.
Every record of what that user did goes with them — which is precisely backwards,
because a departing user is exactly when the trail matters. We got this wrong
once, years ago, and now read every foreign key in a schema with it in mind.
A retention job nobody scoped. “Delete rows older than 90 days” is written
for the events table and applied to everything with a created_at.
An ORM update() on the wrong model. Audit rows should be insert-only at
the type level, not by convention. If your data layer exposes update and
delete for the audit table, somebody will eventually call them, and it will be
in a migration script at the end of a long day.
GDPR erasure, applied without thought. This one is genuinely hard rather than careless, and it deserves more than a sentence: a right-to-erasure request and an append-only audit trail are in real tension. The usual resolution is to separate the identifier from the record — the log keeps “account 7f3a did X”, and the mapping from 7f3a to a person is what gets erased. Decide this before you are asked, not during.
What we do instead
In our own panel the audit table has no update path and no delete path anywhere
in the codebase. Not “no button” — no function. The data layer exposes exactly
two operations against it: audit() to insert, and a handful of SELECTs to
read. Adding a third would be a visible change in review, which is the point.
Three things follow from that, and none of them work without it:
- The activity view means something. A monitoring dashboard over a log somebody could quietly prune would only ever show you what they left behind.
- Deletions are more informative than the records they remove. When an
administrator is removed, the row goes and an
admin.revokeentry stays, naming who did it and when. The absence is explained. - Failed attempts are logged as loudly as successful ones. A refused write is a more interesting event than an allowed one, and a log that only records success is a log of the boring half.
Three rules
- The actor is recorded from the verified identity, never from a request body or a header. An audit row naming whoever the client claimed to be is worse than no row: it is evidence pointing at the wrong person.
- Write it in the same transaction as the change, or accept that you will have changes with no rows and rows with no changes. Know which you chose.
- Store timestamps as an absolute instant, not a local string. Incident timelines get assembled across systems, and “14:05” without a zone is a guess.
None of this is expensive. It costs one table, one insert function, and a standing refusal to add a second way to write to it.