Skip to the first thesis

auditlog.dev

An audit log has two readers. Most logs are designed for the wrong one.

The builder writes it. The auditor reads it — a year later, knowing nothing about your codebase, holding a list of things to spot-check. They sample. They ask you to prove a specific claim about a specific week. Everything that follows is what a log looks like when it is built for that reading rather than for a debugging session on the afternoon it was written.

This is about product audit logs — the record of what people and machines did inside your application. Not CloudTrail, not pgAudit, not syslog. The infrastructure literature covers those well and covers this badly.

One schema and one dataset of 43 events run through all five sections. The fields declared in 01 are what answer the auditor’s question in 05. If the page is right, you can check it against itself.

01

Every field in the schema is a decision.

A schema is the most durable opinion in the system. Endpoints get rewritten and UIs get redesigned, but a column you add to an append-only table is a column you are still reading in four years — and a column you left out is a question you will never be able to answer about the past.

So every field has to survive the same test: would a competent engineer plausibly do this the other way? If yes, the field is a decision and deserves its defense. If no, it is furniture.

type AuditEvent = {
: string
: number
workspace_id: string
: Actor
: string
: Target | null
: UserSnapshot | null
: "allowed" | "denied"
: string | null
: Changes | null
: Record<string, Json>
: Origin
: string
: string
: string
}
 
type Actor =
| { type: "user"; : UserSnapshot }
| { type: "api_key"; snapshot: ApiKeySnapshot; issued_by: UserSnapshot }
| { type: "system"; name: string }
| { type: "agent"; name: string; on_behalf_of: UserSnapshot | null }
| { type: "impersonation"; operator: UserSnapshot; subject: UserSnapshot }
 
type UserSnapshot = {
id: string // an erasure never reaches this one
: string | null // null after an erasure, never before
name: string | null
}
 
type Changes = {
: string[] // what moved
before: Record<string, Json> | null // null when it did not exist
after: Record<string, Json> | null // null when it no longer does
}

16 fields carry an argument. workspace_id does not — it is plumbing, so it is not annotated. A field that cannot survive this treatment does not belong in the schema.

02

Audit events are written at the service layer, or they don't exist.

Completeness is not a property you can add later. A dashboard, a REST call, an MCP server, a Slack agent and a nightly cron are five different doors into the same mutation — and the only place all five have in common is the service that performs it. Emit the event anywhere else and you are maintaining a list of doors, which is a list that is wrong the week after someone adds one.

So the event is written in the same transaction as the change it describes. Not after it, not alongside it, not by something watching the database. Usually that is one more INSERT into a table sitting next to the one you just changed; it only has to become an outbox and a processor when the log lives somewhere the transaction cannot reach. Either way: both commit, or neither does.

dashboardREST APIMCP serverSlack agentcronevery mutation, whatever the doorservice layeremits the eventBEGINUPDATE monitors …INSERT audit_events …COMMIT — both, or neitheraudit_eventssame database, same commitoccurred_at = recorded_atthere is no second moment

One more INSERT, the same COMMIT, the same database. The event cannot survive a mutation that rolled back and the mutation cannot commit without it — which is the whole guarantee, bought without a second table or a process to operate. occurred_at and recorded_at are the same instant here, and most rows in a real log look exactly like this.

03

A log without denied attempts is half a log.

There is a line between audit and telemetry, and if you do not draw it deliberately, volume draws it for you: a check running every thirty seconds will bury every role change you ever recorded. The test is not whether an event is interesting. It is whether someone might later need to prove it happened.

The half most logs are missing is the refusals. An API key attempting a delete it has no scope for is the highest-signal row in the table — and it only exists if the authorization layer writes an event when it says no.

candidate eventverdict
  • It is an observation about the world, not an action taken by anyone in your system.

    There is no actor, so half the schema arrives empty — which is the tell. Keep it in incidents where it belongs, and log the human response (acknowledged, resolved) in the audit log instead. Some teams log both, and pay for it in a table where you cannot see the role change for the state changes.

Three of the twelve are marked contested, which is the honest count. A list where every answer is obvious teaches nobody anything, and the arguments worth having about an audit log are mostly at its edges.

04

Redaction happens at write time. Display-time redaction is a secret with extra steps.

An audit log has to outlive the things it points at. The user gets deleted, the monitor gets removed, the workspace gets renamed — and the log still has to read correctly years later, which is why identity is snapshotted into the event instead of joined to at read time.

Copying is not a promise that the copy is permanent. It is a promise that the copy has its own lifecycle: deleting the account never reaches it, and an erasure request can null the two fields that name a person without touching a single other column of a single other event. Being able to be that surgical is the thing the copy bought. Secrets are a different problem, and they are decided at the write. An append-only log makes every leak permanent, so anything you would not want in there forever cannot be allowed in there once.

notifier.updated · changes as stored
{
  "fields": [
    "url",
    "severity_filter"
  ],
  "before": {
    "url": "https://hooks.slack.com/services/T024F/B08KM/[redacted:write-time]",
    "severity_filter": "all"
  },
  "after": {
    "url": "https://hooks.slack.com/services/T024F/B09PQ/[redacted:write-time]",
    "severity_filter": "error,critical"
  }
}

The credential never entered the table, so there is nothing to leak later. The event still records what changed and who changed it, which is the part anyone actually needs.

actor:tom@northwind.dev
0 eventsthe name is gone
actor:usr_01HQ8TOMW
6 eventswhat they did survives
affected_user:usr_01HQ8TOMW
4 eventswhat was done to them, too
occurred_atactoractionchanged_fieldstarget / affected_useroutcome
erased userusr_01HQ8TOMWauth.login_faileddenied
erased userusr_01HQ8TOMWauth.mfa_enabledmfa
erased userusr_01HQ8TOMWauth.login_succeeded
erased userusr_01HQ8TOMWauth.login_blockeddenied
erased userusr_01HQ8TOMWauth.login_faileddenied
erased userusr_01HQ8TOMWauth.login_faileddenied

What the erasure reached: email, name, origin.ip on 2 events. What it left: origin.ip on 4, all of them denied.

Nothing was deleted. The stable id stayed, so these are still recognisably one account’s events — the failed logins from an address nobody recognised, and the attempt made the day after the account was removed. The email, the name, and the address Tom’s own sessions came from are gone. That trade is available only to a log that copied the identity in rather than pointing at it: there was something here to null.

Two things that word is glossing over. usr_01HQ8TOMW is a pseudonym, not an anonym — it points at a person for as long as anything anywhere maps it back to one, and this log is only one of the places that mapping lives. And an address is an identifier too, which is why the erasure clears origin.ip; it stops at the 4 denials, where the address is evidence of somebody else failing into the account.

Which raises the obvious objection: an append-only table you are allowed to UPDATE is not append-only. The resolution is that erasure is not an exception to the rule but a second, much narrower grant — one that can reach the identity columns of a row and nothing else, can never delete a row, and whose every use writes an audit event of its own. There is a member.data_erased row in this table for exactly that reason.

And if you would rather never issue that UPDATE at all: encrypt the identity fields under a per-subject key and destroy the key instead. The ciphertext stays, the row is never touched, and the name is just as unrecoverable.

05

An audit log is a product surface, and its user is holding a sample request.

Nobody reads an audit log. They interrogate one. The request arrives as a sentence — “show me who was granted admin in March” — and the only question that matters is whether that sentence can be turned into a filter without anyone opening a database console.

Which is why an audit log is queried by structured filters and not by free text. The schema is the query language. Every preset below is a real question, run live against the same 43 events, using only fields that section 01 already committed to.

4 of 43 events · action ∧ created

occurred_atactoractionchanged_fieldstarget / affected_useroriginoutcome
marco@northwind.devmember.role_updatedrolejane@northwind.devaffected_userPTdenied
priya@northwind.devmember.role_updatedrolesam@northwind.devaffected_userNL
jane@northwind.devmember.role_updatedroleerased userusr_01HQ8TOMWDE
jane@northwind.devmember.role_updatedrolemarco@northwind.devaffected_userDE

Answered by
affected_user, because the person whose role changed is not the person who changed it, and both have to be queryable. Note what comes back alongside the two admin grants: a role change that is not a promotion, and an attempt that was refused. The column names the field that moved; the row has the before and after. The filter is not narrowed to the flattering rows, because the schema does not know which rows are flattering.

The whole query language
actor:
Who acted — email, id, or API key name. Matches both identities of an impersonation.
actor_type:
user | api_key | system | agent | impersonation
action:
Exact (member.role_updated) or by category (member.*)
outcome:
allowed | denied
affected_user:
The other party — who the action was done to
target:
Resource type (monitor) or resource id (mon_01HQA1HLTH)
changed:
Which column moved — changed:role, changed:scopes. Reads changed_fields, not the diff.
country:
Two-letter country resolved from origin.ip at write time
request_id:
Everything that came from one gesture
created:
2026-03 · 2026-03-12 · 2026-03-01..2026-03-31 · >=2026-03-12

There is no free-text search. Type a bare word and the log will tell you so — the schema is the query language, and a text box over an audit log is an admission that the schema was never finished.