Most systems have an audit table. Far fewer have an audit trail. The difference is whether anything in the system is capable of modifying a row after it is written.
The property being claimed
When you show an auditor a log, the implicit claim is: this is what happened, and it has not been edited since. If the application’s database user holds UPDATE and DELETE on that table, the claim is unsupported — not because anyone did edit it, but because nobody can demonstrate they did not.
That is the entire game. Not whether it was tampered with. Whether tampering is detectable.
Enforce it below the application
Application-level discipline is not a control, because the next developer does not know about it. Push it into the database:
REVOKE UPDATE, DELETE ON audit_entries FROM app_user;
Plus a trigger that raises on either, so a future privilege grant does not silently reopen the door. Two statements, applied once.
If the platform supports it, write the artefacts to object storage with an immutability policy and a retention period. That moves the guarantee outside the database entirely, which is stronger again.
Chain the entries
Immutability stops edits. Chaining detects deletions and reordering:
audit_entries
id, occurred_at, actor, action, target, detail
prev_hash
entry_hash -- hash(prev_hash + this row's fields)
Removing an entry breaks every hash after it. A daily job verifies the chain and alarms on a break. Cheap to compute, and it turns “we think the log is complete” into something you can run.
Log the decision, not just the mutation
A row-level change log tells you a value went from A to B. It does not tell you who approved it or why, which is what every finding actually asks.
Capture the intent alongside: the request, the approver, the reason, the ticket. status: pending → approved, by: ..., because: ... is evidence. status_id: 2 → 3 is a diff.
What it costs
Two grants, a trigger, a hash column, and a verification job. Perhaps a day. It is the difference between handing over a log and having it accepted.