The moment an app can change data, it owes you an audit trail
The first write-back button is the cheapest moment to get identity, permissions, an append-only log and a reversal path right. Every later moment costs more.
An internal tool almost never starts out dangerous. It starts as a table on a screen. Someone in operations was exporting the same query into a spreadsheet several times a week, so a developer wrapped it in a web page, added a search box and a date filter, and everyone was pleased. Nothing in that app can do harm, because nothing in it can change anything. The worst outcome is a stale number and a confused meeting.
Then the request arrives, and it is always reasonable. The exceptions on this screen are the ones we correct by hand anyway, so why are we correcting them in the source system and then waiting for the pipeline to catch up. Give us a button. Approve, adjust, override, waive, release, whatever the domain happens to call it. A field goes into a form, a row goes into a table, and the app has quietly stopped being a report.
That is the moment the obligations change, and it is worth being blunt about why. A read-only tool that is wrong produces a bad decision, and a bad decision is usually caught by someone who knows the number looked odd. A write-back tool that is wrong produces a bad record, and a bad record travels. It gets copied into a downstream model, quoted in a customer conversation, included in a return to a regulator. Nine months later somebody asks who set that value and on what basis, and there is no honest answer available.
Four things the first button brings with it
These are not a maturity ladder to be climbed over four quarters. They are the entry cost of shipping a single write path, and they are cheap in the week you build it because the write path only exists in one place.
- Identity every change is attributed to a named human being, not to the application, not to a shared service account, not to a token pasted into a config file two years ago.
- Authorisation who may perform which action is derived from something that changes when a person changes role, and is enforced on the server, not implied by which buttons the interface chooses to render.
- An append-only record a separate log that says what changed, from what to what, by whom, when, and why, which the application itself is not permitted to update or delete.
- A reversal path a documented way for an authorised person to undo or compensate a change through the app, without an engineer opening a database console.
Identity has to survive the shortcuts
The common failure here is not malice, it is convenience. The app authenticates users at the front door, then connects to the database with one pooled account, and every row it writes carries updated_by set to the application name. That column is worse than useless, because it looks like an answer. The fix is to carry the authenticated principal all the way through to the write, and to reject the write if it is absent. Machine-initiated changes deserve the same treatment: a nightly reconciliation job is a legitimate actor, but it should be recorded as that job, with a reference to whoever approved the rule it is applying. Single sign-on against the company directory does most of this work for you and gives you the one property a local user table never will, which is that leavers actually leave.
Permissions that survive a change of role
Granting access is easy and nobody forgets to do it, because the person is standing there asking. Revoking is where models rot. An analyst moves to a different team, keeps the approval right they were given for one project, and long after that project closed they still hold it. If your role assignments live in a table inside the app, nothing in the company's joiners and leavers process will ever touch them. Bind roles to directory groups instead, so the movement that HR already records is the movement your app already respects. Then check permissions in the handler that performs the write, not only in the component that draws the screen, because a hidden button is a suggestion and an unguarded endpoint is a door.
The question a permission model has to answer is not who can approve this today. It is who could have approved it last March.
The pattern, stated plainly
Append-only, or it is decoration
Adding updated_at and updated_by columns to the record itself is the most popular audit design and it is not an audit trail. It tells you about the most recent write and destroys the evidence of every write before it. What you want is a separate table, in its own schema, that the application role can insert into and cannot update or delete. In Postgres that is a few lines of grants and it is the single most valuable control on the list, because it converts an operational log into something a person can defend in a meeting. Write the audit row inside the same transaction as the change, so it is impossible to have one without the other, and store the before and after states as structured values rather than a rendered sentence. Database triggers give you coverage of writes the application forgot about; application-level logging gives you intent, which the trigger cannot see. Serious systems keep both, at different layers, and reconcile them.
- 01Authoriseresolve the actor, check the permission for this specific action on this specific record, and refuse cleanly if either is missing.
- 02Validatecheck the proposed value against the contract for that field, including the ranges and referential rules the pipeline downstream is going to assume.
- 03Read the prior stateinside the transaction, so the before value you record is the value you actually replaced and not one that shifted under you.
- 04Apply and appendwrite the change and insert the audit row together. One transaction, one commit, no path that produces a change without a record.
- 05Emitpublish an event after commit for anything downstream that needs to react, carrying the audit identifier so the two can be joined later.
The reason field is the part people skip
An audit trail that captures what changed but not why is only half an answer, and the half that matters least. A free-text box gets filled with "as discussed" and "per email" and, after a while, a single space character. A short controlled list of reason codes, agreed with the people who actually do the work, plus optional free text and a reference to the ticket or case, turns the log into something you can aggregate. That aggregation is where the real return sits. If one reason code dominates your manual overrides, you have not found a compliance problem, you have found an upstream data defect that somebody has been absorbing by hand for a year.
A reversal path that does not require an engineer
This is the test that separates a designed system from a demo that survived. If undoing a mistaken approval requires a developer with production access and a hand-written statement, then the control is that developer, not the application, and your audit trail now has a hole in exactly the place where things go wrong. Reversal should be a first-class action in the app, subject to its own permission, producing a new entry that references the original rather than editing it. Some actions genuinely cannot be reversed, because money left, or a message was sent, or a shipment moved. For those the control has to sit earlier, as a second pair of eyes before the action commits, and the pause that imposes is the price of the action being irreversible.
Where this is the wrong answer
Not every write-back deserves this apparatus, and it is dishonest to pretend otherwise. If the app writes to a working table that a person then re-keys into the system of record, the trail that matters lives in the system of record, and a second one is theatre with a storage bill. If the values being written are annotations that nothing downstream reads, a simple history table is enough. And there is a real cost to logs nobody queries: they accumulate personal data under a retention policy that was never set, which converts a control into a liability. Before building any of it, ask who will read the log, in what situation, and how quickly they need an answer. If nobody can name that person, build less. The same candour applies to how Woodfrog works. Our week-one audit produces a one-pager in our own format, and where a client already has a controls function with an established evidence standard, adopting theirs is the right call and inventing a parallel one is waste.
The reason to do this in the first week is not diligence, it is arithmetic. Every control on this list can be added later, but history cannot. When the question finally arrives, and it arrives as an audit, a dispute or an incident, you can start recording from today and you can reconstruct a partial picture from database backups at considerable expense, and neither of those recovers the changes already made, all written by the same service account with no reason attached. The button is cheapest to build correctly on the day it does not exist yet.