launch-quickly
Patterns

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 targetId and a few small, human-readable metadata facts from { input, data }. data is the handler's return value, typed unknown — 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.

On this page