Audit logging
How the audit trail works — the .audit() stage on the action client, what gets recorded and what must not, immutability via RLS, and the lint rule on destructive actions. Use when adding mutations, when the require-audit-on-destructive rule fires, or when compliance questions come up.
Generated from
.claude/skills/audit-logging/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
Every audited action is one .audit() call on the action-client chain. The
client writes the row AFTER the mutation succeeds, with the actor taken from
the server's own session — features never insert into audit_events directly.
The shape
export const removeProject = action
.input(deleteProjectSchema)
.authed()
.can('projects.delete')
.rateLimit('mutations')
.audit('project.deleted', ({ input }) => ({ targetId: input.id }))
.mutation(async ({ input, ctx }) => { … });- The name is
resource.verb, past tense:project.deleted,member.removed,invitation.created. Greppable beats clever. - The optional callback extracts
targetIdand a few small, human-readablemetadatafacts from{ input, data }.datais the handler's return value, typedunknown— narrow it in the callback.
What must NOT go in metadata
Never the whole input. Forms carry things an audit log must not: passwords,
tokens, message bodies, uploaded content. Record identity ("name: Q3 report"),
not payload. The viewer shows metadata verbatim to every member with
audit.read.
Destructive actions are REQUIRED to audit
@launchquickly/require-audit-on-destructive: any chain declaring a .can()
permission ending in .delete or .remove must include .audit(). Deletion
is exactly the operation someone later asks "who did that?" about. The
generator already emits .audit() on all three CRUD actions — this rule
exists for the hand-written ones.
Immutability is a database property
The RLS policies on audit_events grant SELECT and INSERT only. There is no
UPDATE or DELETE policy, so the request-path role cannot rewrite or erase
history — the forbidden statements silently affect zero rows (pinned by
src/lib/audit.integration.test.ts). Retention and pruning belong in a job
running under the system role, deliberately outside the request path.
Failure semantics
The audit write runs after the mutation's transaction has committed, so a
failed write is logged loudly but does not turn a succeeded action into a
reported failure — the gap is bounded at one event. If your compliance
posture needs atomicity, move the recordAuditEvent call inside the
mutation's own withTenant transaction and accept the coupling. That
trade-off is documented at writeAudit in src/lib/action-client.ts.
Recording vs viewing
Recording happens on every plan. VIEWING (/settings/audit) is gated twice:
the audit.read permission (owner/admin) and the audit.log billing feature
(pro and up) — history does not start when the customer upgrades; it is
already there. Outside an action (a webhook, a job), call
recordAuditEvent(ctx, …) from src/lib/audit.ts yourself and pass the
actor explicitly.
AI patterns
How AI features work here — the provider seam, metered entry points, streaming route vs one-shot action, and the usage budget wired into billing. Use when adding any model call, or when AI spend and entitlements are involved.
Observability
Logging, request ids, error reporting and the health endpoint — the structured logger, why console is a lint error, requestLog/reportError, and the job-run logger. Use when adding log lines, when no-console fires, or when wiring a reporter like Sentry.