Using Audit

Emit

await c.var.emit({
  action: "board/deleted",
  outcome: "success",
  actorType: "user",
  actorId: c.var.auth.userId,
  resourceType: "board",
  resourceId: board.id,
  tenant: organization.id,
});

c.var.emit is on every request whether or not audit is composed — that seam, and the event it takes, live in core rather than in this package. With no audit capability, it is a no-op — so you can emit from a capability or from your own route without a conditional and without an import.

occurredAt is stamped for you. project, environment and worker are stamped by the recorder and cannot be set here.

Record the denials

await c.var.emit({
  action: "board/deleted",
  outcome: "denied",
  actorType: "user",
  actorId: c.var.auth.userId,
  resourceType: "board",
  resourceId: id,
});

A trail of successes cannot show you somebody trying. Denials are what a security trail is for, and denied is a first-class outcome rather than a flavor of failure.

Choose the severity separately

Severity is orthogonal to outcome. A successful production secret rotation is critical; a failed sign-in is usually info.

Setting severity from the outcome throws away the axis — and it is the axis alerting reads.

Name your actions under your own domain

board/deleted, not deleted and not audit/board_deleted.

The taxonomy is federated by domain for the same reason table prefixes and error codes are: one capability’s actions cannot collide with another’s, so nobody has to reason about who registered what first.

Set tenant if you have one

Whoever the action was done to, not who did it.

Skip it and multi-tenant reads are impossible later — the fact is only knowable at the time of the action, and reading a membership table for it afterwards rewrites history every time somebody changes teams.

A single-tenant application legitimately never sets it, and null is a first-class value rather than a gap.

Keep metadata small

metadata: { provider: "google", reason: "INVALID_OTP" }

Codes and ids. Never a secret, never a credential, never an email address — the trail is queryable, long-lived, and currently never pruned.

What you never have to handle

The emit does not throw. A write failure is logged and the action proceeds.

A retry after a transport hiccup does not duplicate. The event id is minted before the insert and held stable.

You do not need a try/catch around it, and wrapping it in one is a way to accidentally swallow the error your own handler was about to throw.

Query it from your own code

The typed filter is the primary seam; the control-plane routes are a surface over it. Your own admin page can query the trail directly with the same filter type — by actor, action, time range, resource, outcome, origin and tenant.

ESC