Audit log
Who did what, recorded by the framework and immutable by the database.
Every launch-quickly app records an audit trail: who did what, in which organization, when. The mechanism is one method on the action client — features never write audit rows by hand.
One line per audited action
export const removeProject = action
.input(deleteProjectSchema)
.authed()
.can('projects.delete')
.rateLimit('mutations')
.audit('project.deleted', ({ input }) => ({ targetId: input.id }))
.mutation(async ({ input, ctx }) => {
// …
});The action client writes the event after the mutation succeeds. The actor is taken from the server's own session, never from input — an audit log whose "who" can be supplied by the request is testimony, not evidence. The callback extracts the target id and a few small human-readable facts; never the whole input, because forms carry things an audit log must not.
lq generate feature emits .audit() on all three CRUD actions, so
generated slices arrive audited.
Destructive actions cannot skip it
The lint preset ships require-audit-on-destructive: an action chain that
declares a .can() permission ending in .delete or .remove must declare
an audit event, or lq check fails. Deletion is exactly the operation
someone later asks "who did that?" about, and an audit log with holes where
the deletions should be certifies innocence it cannot check.
Immutable by the database, not by convention
audit_events carries row-level security policies for SELECT and INSERT
only. There is no UPDATE or DELETE policy, so the application's database role
cannot rewrite or erase history — the forbidden statements silently affect
zero rows. That property is pinned by an integration test that issues the
forbidden statements through the real request-path role
(src/lib/audit.integration.test.ts).
Retention and pruning belong in a background job under the system role, deliberately outside the request path.
Recording is free; the viewer is paid
Events are recorded on every plan. The viewer at /settings/audit is gated
twice: the audit.read permission (owner and admin), and the audit.log
billing feature (Pro and up) — which means history does not start when a
customer upgrades. It is already there, waiting.
The audit.log string in src/config/billing.ts names a working, tested
capability — like every feature string in that file.
Auditing outside an action
Webhooks and jobs have no action client. Call recordAuditEvent from
src/lib/audit.ts directly, with a TenantContext and an explicit actor.
The failure semantics are documented at writeAudit in
src/lib/action-client.ts: the write happens after the mutation commits, is
logged loudly on failure, and never turns a succeeded action into a reported
failure.