Audit overview

Somebody’s account was changed on Tuesday and nobody knows who did it. That is the question this capability exists to answer, and the reason it has to be composed before the Tuesday rather than after.

@pithy-sh/audit records security-relevant actions — sign-ins, token refreshes, entitlement grants, secret rotations, admin config changes — as durable, queryable rows in your own D1, attributed to the right actor.

The seam is in core, so nothing imports this package

The contract — the event shape, and emit() on the request context — lives in @pithy-sh/core. Any capability records an event through c.var.emit(...) without importing this one.

With no audit capability composed, emit() is a no-op recorder. So an audited action can never break for want of auditing, and a capability that records events works identically in a project that never wanted a trail. This package is the recorder that seam resolves to once it is there.

That is the same shape as the translator seam and the entitlement seam: present and inert, filled by composition.

Synchronous, and never fatal

emit() awaits a direct insert inside the handler, before the response — so the event is durably persisted by the time the caller gets a reply, rather than racing a background task the request might outlive.

A write failure is logged and never fails the audited action. The insert rides out transient database faults with a retry whose idempotency guard means a hiccup never double-writes. An audit trail that can take your login down is worse than one with a gap in it.

Where an event came from is stamped by the writer

Every row carries the project, the environment and the Worker, stamped by the recorder from that Worker’s own vars — never by the emitter, which has no way to set them and no way to override them.

That is the point: origin is a property of the writer rather than of the action, so a route cannot claim to be another Worker or another environment.

It also solves a specific problem. Two Workers that declare the same binding share one database, so the Worker name is the only thing that tells their events apart. And project and environment used to be carried solely by the name of the database a row sat in — which a row does not carry, so an exported or aggregated trail lost both.

All three are nullable, permanently. A Worker scaffolded before those vars existed carries none of them, a CLI action came from no Worker at all, and no row written before the columns existed can ever be back-filled. null means not recorded, and nothing invents a value to avoid it.

Whose action it was

The fourth dimension is the tenant, and it is the only one the emitter supplies — whose account the thing was done to, as distinct from who did it:

await c.var.emit({
  action: "admin/config_changed",
  outcome: "success",
  actorType: "user",
  actorId: user.id,
  tenant: organization.id,
});

actorType is what keeps a management client’s actions answerable separately from your users’. A control-plane call lands under its own actor kind, so what the dashboard did and what my customers did are two queries rather than one.

What it deliberately does not do

It is not a log. Operational output — a request line, a timing, a stack trace — goes to the logger, which is a different seam with different retention and different volume. An audit event is a decision, not a diagnostic.

It is append-only. The control-plane surface it contributes is reads only. There is no edit and no delete, because a trail somebody can edit is not evidence.

It does not choose your retention. Rows stay until you remove them. What a compliance regime requires is yours to know.

It is not KV. The store of record is D1, deliberately: an audit log is a query workload — by actor, by action, by time range, by resource, by outcome — and KV is get-by-key only.

It cannot audit its own installation. The first pithy add audit records nothing, because nothing can record the arrival of the thing that does the recording.

When you would reach for it

The first time somebody asks who changed that, which is always after you needed it. Compose it early: it is one table and one binding, it costs nothing when nothing emits, and every capability that records events starts recording the moment it is there.

Specifically: if you have staff who can act on customer accounts, if you connect a management client to production, or if anything you do is reviewed by somebody who was not in the room.

What it needs

Nothing. No peer capabilities, no secrets, and one binding — the D1 it writes to, defaulting to the shared app database.

Every capability that records events benefits: auth records sign-in, token refresh and device revocation; secrets records every set, rotation and removal; payments records entitlement grants; the CLI records capability installs, deploys and schema resets.

ESC