Audit log
What gets recorded on every write, who can read it, and what it deliberately leaves out.
Every write in Quoin lands one row in audit_log. A write can be a key created, an environment deleted, a member
invited or a role changed. Quoin writes the row in the same atomic batch as the write itself. No path changes
workspace state without also writing the row that says who changed it, when, and how.
Built end to end
The table, the paired-write mechanism, audit.list/audit.export and the ten-table quoin-admin export/import
tool are real, tested code as of QN-11c. The Audit log screen (/audit-log) is built as of QN-11c-ii. The
screen has these parts: a filter row (actor, object type, date window, and a key filter that matches any part of
the subject key); one row for each write, with its actor, what changed and the note; a row detail that shows the
stored fields; "Export CSV" for owners and admins; and "Load older" on the same cursor that the API uses. Every kind
on this page renders there on the day a writer for it exists. The experiment and decision-record kinds have no
writer yet. This page describes what the API records. This is exactly what the screen shows.
What's recorded
Each row carries these items:
- What —
kind(for exampleapiKey.revoked,member.role_changed,environment.created) andsubjectKey. ThesubjectKeyis the thing that a person can scan, and that the row is about. It is the label of an API key, the key of an environment, or the email of an invited or removed person. - Who —
actorIdandactorLabel. TheactorLabelis the name of the actor at write time. The historic rows of a deleted or renamed actor still read correctly, becauseactorLabelis never a live join.actorIdkeeps the id even when the id resolves to no one. Quoin never overwrites it, and it never turns the row into an orphan. - How —
via:web,cli,api,guardorsystem. When a guard pauses a flag by itself, the row has no human actor at all (actorId: null,actorLabel: 'guard:<name>'). See Guards. - When —
createdAt. - A note, when the write collected one. The one-writer rule requires a note on a flag edit or revert
(
flag.edited/flag.reverted, real as of QN-03a). The key, environment and member events take no note.
Append-only, kept until purge
Quoin never updates or deletes an audit_log row. The one exception is the purge of a workspace (30 days after
deletion, the same as every other table). Review does not enforce this rule as a convention. Two checks that fail
the build enforce it:
- An AST-checked test scans every
.ts/.tsxfile in the server and database packages for a query-builderupdateordeleteon the table. The test fails if one appears anywhere outside that one purge call. - The test cannot see a raw
sqltemplate, so a second check covers it. Theraw-sql-outside-dialectrule of the import lint confines the rawsqltag to the one dialect module of the database package. Because of this, the only file where a rawUPDATE audit_logcould be written is a short file that people review with care.
Both checks are static checks over the code. They are best-effort by design. The AST test resolves the table through the imports and aliases of a file. The README of the database package lists the shapes that the test does not follow. A guarantee at the level of the database grant needs the Postgres path. The retention rule is therefore simple to state precisely: forever, until the workspace itself is purged.
Reading it
audit.list is read-gated. Every role can see it, viewer included, the same as Flags, Experiments and Metrics. It is
a cursor-paginated read that you can filter. The filters are:
- the key
- the actor
- the object type (
flag,experiment,metric,member,apiKey,environment,audit) - a date window
A page has 20 rows. This is the same page size that the revision history of Q4 uses.
audit.export is gated to admin and owner (the manage-keys-members action of the matrix). It writes its own
audit row, audit.exported, at the moment it runs. The row has the filter and the row count that it exported. The
export of the log is itself logged. The export returns one of these formats:
- CSV with the columns time, actor, via, kind, subject and note, and one row for each audit row. If a field begins with
=,+,-,@, a tab or a carriage return, Quoin adds an apostrophe before it. Then a spreadsheet shows it as text and does not evaluate it. The exact set isFORMULA_LEADinapps/web/server/trpc/procedures/audit.ts. This sentence follows that set, and the set does not follow this sentence. - JSON with every column, including the free-form
fieldsbag, verbatim.
The "Export CSV" link of the screen calls the CSV format. The JSON format is a signed-in session's export for tools of
its own. Today audit.export answers only a signed-in web session, in either format. It refuses an API key of any
scope as not signed in, whatever the role of the key. This is by ruling, not by gap (07d qn11c-qa-1, affirmed again
by qn03a-build-3). QN-03a opened the shared role check to operator keys. As a result, audit.list now answers the
CLI key of a member, for any role, because audit.list is read-gated. QN-03a deliberately kept the session check of
export, and QN-03a-ii kept it too. QN-03a-ii is the slice that ships the quoin CLI. There is no quoin audit
command in v1 (07d qn03a-ii-plan-11).
What never appears
ipand OTP codes never reachaudit_logat all. Email addresses also never reach the free-formfieldsbag. The console-log security channel uses the same redaction rule. Quoin applies the rule once, and both share it. Formember.*rows, the email of the invited or removed person is thesubjectKeyof the row (see "What's recorded" above). The email is the identity of the object. Quoin keeps it in the table of the workspace itself. It is not a leak in a log line.- Sign-in events (
auth.sign_in.succeeded/.failed, OAuth linking, session revocation, OTP rate limiting) are not in this table. This is by design, not by gap. They happen before a workspace is selected, and theworkspace_idcolumn ofaudit_logisNOT NULLon every other row that it carries. They stay on the structured console-log channel that Workers Logs already collects. "The same pipeline" that the sign-in audit line of QN-01 itself promises means the same structured-JSON mechanism. It does not mean the same table. - A rendered environment.
audit_logcarries noenvironment_id. The log is workspace-wide across every environment. This matches the rule that the sidebar switcher is the only place where you choose an environment. A write that needs environment context, such as the per-environment rollout edit of a flag, carries it inside thefieldsJSON of the row. It is not a column that every other kind would carry for nothing.