Most audit trails are not audit trails. They are log lines someone hopes to reconstruct a story from later.
The pattern is familiar. A model changes, something gets written — a message, a serialized blob, a "user updated" event. Then a dispute arrives: who changed the payout requisites, when, and what was the value before? Now an engineer is grepping, joining timestamps, and inferring intent. That is forensics. It happens after trust is already gone.
An audit record has to answer three things at the moment of the write, not afterwards: which fields changed, what the old and new values were, and which actor is responsible. If any of those is derived later, it is a guess.
Two things make that hold in production.
First, the changeset is typed and field-level. Not a text description of a change — a structured diff you can query, compare, and assert against in a test.
Second, the actor is resolved at write time. Requests have a user; background jobs, admin scripts, webhook handlers and migrations do not. If the actor is only available from request context, half your writes will be attributed to nobody — and those are usually the interesting ones.
The part teams skip: keep the diff core framework-free. If the logic that computes a changeset only runs inside the ORM's signals, the guarantee is only testable inside a full stack, so in practice it is barely tested at all. Pull it out and it becomes plain input-output — old state, new state, actor — verifiable in isolation.
We wrote the long version up here: https://shipmindlabs.com/blog/an-audit-trail-is-a-typed-changeset-not-a-log-line/
For those of you running compliance-sensitive systems: how do you attribute changes made by background jobs and scripts, where there is no request user to fall back on?