The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is an audit trail?
A customer’s security and compliance team needs to answer, at any time: who accessed or changed this, and when? — and to trust the answer completely, even against the people the log is meant to hold accountable. Ordinary application logs aren’t enough: they’re unstructured, incomplete, mutable, and not built to be trusted evidence.
Build a purpose-built trail: capture a structured who/what/when on every action, write it to an append-only immutable store, make it tamper-evident with hash chaining, enforce a retention window, and let auditors query it under access control. It turns "we think it was fine" into provable evidence.
Step 1 · The skeleton
Record what happens
The naive version: the Application writes a line to a log store when something happens. It works until an auditor asks a precise question — and you find the logs are patchy, mutable, and missing the who or the when.
Stand up the Application writing events to a dedicated audit store, separate from ordinary debug logs. Everything that follows makes those events complete, trustworthy, and durable enough to be evidence.
Why this piece earns its place
The thing this skeleton quietly assumes is that the application remembers to emit. Coverage becomes a property of every code path rather than of the system, and a missing entry is the one defect a trail can never show you — an action nobody recorded looks exactly like an action that never happened. So I wouldn’t leave the emit scattered across call sites. Put it at a chokepoint every consequential action already crosses — the authorization check, the service boundary, the layer that has already resolved who the caller is — because that is also where identity is in hand, and a new endpoint inherits the recording instead of having to remember it. Then treat the set of auditable actions as a reviewed inventory with a test per entry, not a convention. The other half of the assumption is that this store stays separate. Keep it separate from day one: the moment audit events share a pipeline with debug output they inherit that pipeline’s sampling, its rotation and its retention, and every one of those silently destroys evidence.
What the new pieces do
- Actorclient
- Anyone who does something worth recording — a user viewing a record, an admin changing a setting, a service calling an API. Every consequential action needs a trace.
- Applicationbackend
- The system doing the real work. On every consequential action it emits an audit event — the moment the record is created, not reconstructed later from ordinary logs.
- Immutable Storedata
- Write-once, read-many storage that cannot be edited or deleted before its retention expires. The foundation: an audit log you can change is not an audit log.
Step 2 · Capture the facts
Structured who / what / when
"User did something" is useless to an auditor. You need the full fact — identity, action, target, timestamp, source — captured consistently on every consequential action, not reconstructed later from scattered logs that may not agree.
An auditor asks "who changed this record, and when?" What must every audit event capture?
Recording only the action leaves out the who, the when, and the target — exactly what an auditor needs. A trail that can’t answer "who and when" isn’t an audit trail. Completeness is the point.
Debug logs are inconsistent, incomplete, and may be rotated away before the auditor asks. Audit events must be captured deliberately at the moment of the action, in a fixed structure, not reverse-engineered afterward.
Capture each action as a consistent structured record — identity, action, target, timestamp, source, and before/after where relevant — at the moment it happens. That’s a fact an auditor can query and trust.
Add an Audit Capture step that records a structured event for every consequential action — who, what, when, where, and before/after where it matters — the moment it happens, in a consistent schema an auditor can query.
Why this piece earns its place
The knob this step introduces is scope: which actions count as consequential, and how much of each one you copy into the record. I’d set it from the questions an auditor actually asks — who changed permissions, who read this customer’s file, who exported data — and work backwards to the events that answer them. Too narrow and the gaps stay invisible until a review asks the one question you didn’t record. Too wide and two costs land together: every hot read path now carries a write, and the full before/after of sensitive rows gets copied into the one store you deliberately cannot edit and cannot delete early. Duplicating the sensitive database into the place with the strongest write-once guarantee is how a trail becomes the largest liability in the system. So record identifiers and the shape of the change — target, field, old and new where that is meaningful — rather than whole records, and decide it per action instead of by a blanket rule.
- who·what·when·whereper event
- at the momentnot later
- one schemaqueryable
What the new pieces do
- Audit Captureservice
- Records a structured event for every action: who (identity), what (action + target), when (timestamp), where (IP/session), and the before/after where it matters.
Back of the envelope
- structured event per action
- identity, action, target, time, source
- captured at the moment
- never reconstructed from debug logs
- consistent schema
- so the trail is queryable
Step 3 · Can’t be changed
Append-only + immutable
If an audit entry can be edited or deleted, the trail is worthless — the very people it holds accountable could rewrite history, and an auditor can’t trust any of it. And writing audit synchronously on the hot path would slow the app.
The people an audit log holds accountable could edit it. How do you make the trail trustworthy?
An audit store must be write-once, read-many: entries can be added but never modified or deleted before retention expires. If no one — not even an admin — can change history, the trail becomes trustworthy. Carry events over a durable pipeline so audit never blocks the app.
Admins are exactly the people an audit log needs to hold accountable, so "admins can edit" defeats the purpose. The guarantee must be that no one can edit — enforced by the storage being append-only, not by permissions.
Backups help against loss but not against tampering — a determined insider edits the live log and the backup, or edits before the backup runs. Immutability has to be a property of the store, not a recovery afterthought.
Write audit events to an append-only, immutable (WORM) store — entries can be added but never edited or deleted before retention expires. Carry them over a durable Append Pipeline so auditing never blocks or slows the application.
Why this piece earns its place
Taking the write off the hot path buys latency and costs a window. The action commits, the event is still in flight, and a crash in between loses it — and because the store is append-only you can’t quietly slot the missing entry in afterwards without weakening the exact property that makes the trail worth having. So make the handoff atomic at the source: write the event in the same transaction as the change it describes, into an outbox the pipeline drains, so either the event exists or the action didn’t happen. Then give every source a monotonic sequence number, because a gap you can see is an incident and a gap you can’t see is a false clean bill of health. The related call is backpressure. When the pipeline saturates something has to give, and for genuinely consequential actions I’d fail the action rather than drop the record — an operation nobody can account for is worse than an operation that didn’t run. That is the business’s call to make deliberately, not to discover during an incident.
- append-onlyWORM
- no editsno deletes
- offthe hot path
What the new pieces do
- Append Pipelinecache
- A durable, ordered pipeline that carries events to the immutable store without blocking the app — so audit never slows the hot path, and no event is lost on a spike.
Back of the envelope
- write-once, read-many
- add entries, never modify
- immutable even to admins
- the point of an audit log
- durable async pipeline
- audit never blocks the app
Step 4 · Prove it wasn’t altered
Tamper-evidence
Append-only stops ordinary edits, but a privileged attacker who reaches the storage layer could still splice in, delete, or reorder records. You need to be able to prove the trail is intact, not just assert it.
A privileged attacker reaches the storage and alters records. How do you make tampering detectable?
Encryption protects confidentiality, not integrity — an attacker who can write can still splice or delete ciphertext records without being detected. You need tamper-evidence (detect changes), which encryption alone doesn’t give.
Each entry includes a hash of the previous entry, forming a chain; any edit, deletion, or reorder changes a hash and breaks the chain, which is detectable. Periodically signing or publishing the chain head anchors it. Tampering becomes provable, not just prohibited.
Dual storage helps only if an attacker can’t reach both, and comparing copies is expensive and racy. A hash chain gives tamper-evidence within a single store, cheaply and verifiably — the standard approach.
Make the trail tamper-evident: each entry includes a hash of the previous one, forming a chain, and you periodically sign or publish the chain head. Any edit, deletion, or reorder breaks a hash and is detectable — you can prove the log is intact.
Why this piece earns its place
A chain on its own only proves internal consistency, and an attacker who can rewrite entries can usually recompute every hash after the one they touched — the chain then validates perfectly against a head they also rewrote. All the weight sits on the anchor. The head has to leave this trust domain on a schedule: signed with a key the audit system can’t use by itself, published to a system with different operators, or countersigned by the customer. What anchoring buys is a boundary in time — everything before the last anchor is frozen, everything after it is still forgeable by whoever holds write access. So the interval is the real knob: anchor rarely and you hand an attacker a wide editable tail; anchor constantly and you pay signing cost plus a dependency on that external system staying up. The last piece is that none of it matters unless verification runs. Re-walk the chain on a schedule and on every export, and alarm on the walk failing to finish — a verifier that quietly stopped looks identical to a trail that keeps passing.
- hash chainper entry
- break = detecttamper-evident
- signthe head
What the new pieces do
- Tamper-Evidenceservice
- Chains each entry to a hash of the previous one (and periodically signs the head). Any edit, deletion, or reordering breaks the chain and is detectable — proof, not trust.
Back of the envelope
- each entry hashes the previous
- a verifiable chain
- any change breaks the chain
- detectable tampering
- periodically sign the head
- anchors the history
Step 5 · Keep it the right length
Retention + archival
Regulations require keeping records for a set period (often years) — but keeping everything hot forever is expensive, and keeping some data too long is itself a compliance and privacy risk. The trail needs a defined lifecycle.
Add Retention: enforce the required window per record type, move older entries to cheap cold/archival storage, and delete once the window (and any legal hold) expires. The trail satisfies the regulation without growing without bound or over-retaining.
Why this piece earns its place
Retention promises in two directions at once, and the day they collide is the day an erasure request lands against an entry whose window still has years to run. The store is doing exactly what it was configured to do, and there is no lever to honour the request. Step 2 already kept whole records out of this store; what retention adds is that the identifiers left behind still resolve to a person, so the request reaches them anyway. So encrypt any personal payload under a per-subject key held outside the audit store, and erase by destroying the key — the entry stays, its place in the chain stays, and the content simply becomes unreadable. That relocates the hard part rather than removing it: the key store now has to outlive every window it serves, and a key lost early is an erasure nobody asked for. The second collision is with expiry itself. Dropping old entries removes links from a structure built to detect exactly that, so segment the chain per retention period and keep each segment’s signed digest after its entries go, or a routine lifecycle job is indistinguishable from tampering. And legal hold has to suspend expiry mid-window, which means the policy is data the system evaluates, not a value stamped onto each object at write time.
- N yearsretention
- hot → coldtiering
- legal holdoverride
What the new pieces do
- Retentionservice
- Enforces the compliance retention window — keep for N years, then archive to cold storage or delete — so the trail satisfies the regulation without growing without bound.
Back of the envelope
- retention window per type
- satisfy the regulation
- archive old to cold storage
- cheap long-term keeping
- delete past the window
- over-retention is a risk too
Step 6 · Let auditors in — carefully
Query + access control
Auditors need to search the trail fast — by actor, target, or time — but the audit log itself contains sensitive data, and reading it is a privileged, sensitive action. Uncontrolled access to the audit log is its own breach.
Build a Query Index over the immutable store so auditors search quickly without touching the records, and put it behind Access + Redact: control who may read, redact sensitive fields on read, and — crucially — audit the audit access itself. Reading the trail is a recorded action too.
Why this piece earns its place
Look at what an auditor actually reads. The entries are immutable and chain-verified, but the index in front of them is a derived, mutable copy living outside that guarantee — and it is the surface every answer comes from. Drop documents from it, or alter one, and the auditor sees a false view of a store that is perfectly intact and still verifies. So the index is a cache and never the record: rebuild it from the store, resolve every result back to the entries it names and verify their chain segment before the answer is shown, and treat a disagreement between a count from the index and a count from the store as an incident rather than a reindex job. Redaction has the same shape — it happens on read, so the unredacted field is still there, and the control is only as strong as the path that can’t be bypassed. Then bound the recursion this step creates: reads are recorded, and those records are themselves readable, so keep a read event to who, which query and when, or one broad investigation buries the actions you were looking for.
- indexfor search
- redacton read
- auditthe auditors
What the new pieces do
- Auditorclient
- A compliance officer, security reviewer, or investigator who needs to answer "who accessed this, and when?" — and to trust that the answer hasn’t been tampered with.
- Query Indexdata
- An index built over the immutable store so auditors can search by actor, target, or time range quickly — without being able to modify the underlying records.
- Access + Redactservice
- Controls who may read the trail and redacts sensitive fields on read — because the audit log itself contains sensitive data, and reading it is an audited action too.
Back of the envelope
- index over immutable store
- fast search, no mutation
- access control + redaction
- the log holds sensitive data
- audit the audit access
- reading the trail is recorded
You did it
You just designed an audit & compliance trail.
Everything you assembled, in order
- A dedicated audit trail is evidence, not debug output — different guarantees.
- Capture a structured who/what/when/where on every consequential action, at the moment.
- Write append-only to immutable (WORM) storage — unrewritable even by admins.
- Hash-chain entries so any tampering breaks the chain and is provably detectable.
- Enforce retention: keep long enough for the rule, archive, and delete when allowed.
- Index for auditors, gate and redact access, and audit the audit access itself.