The event model

The seam is in core, not here

The contract — the event shape and c.var.emit() on the request — lives in core. So any capability records an event without importing this package.

With no audit capability composed, emit() is a no-op recorder. An audited action can never break for want of auditing. This package is what that seam resolves to once audit is composed.

The write is synchronous and non-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.

And it never throws. A write failure, or an invalid event, becomes a namespaced error handed to an error callback and logged. audit/write_failed never fails the action it was recording.

The insert rides out transient database faults with a retry, and its always-on idempotency guard means a retry after a transport hiccup never double-writes — the event id is generated before the insert and held stable across attempts.

The store of record is D1

KV is deliberately not an option. 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.

The table is one Zod object: the app shape on one side, the SQLite row on the other, every conversion through a codec.

Four columns the emitter cannot set

project, environment and worker are stamped by the recorder, from the Worker’s own vars.

Two Workers declaring the same binding share one database, so worker is the only thing telling 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 these vars existed carries none of them; a CLI action came from no Worker at all; and no row written before the columns existed can be back-filled. null means not recorded, and nothing invents a value to avoid it.

And one the emitter must

tenant is the mirror image: whose action it was is a property of the action, and no environment variable can know it.

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

actorId is not that column. It answers who — one of five kinds of principal — not for whom — and the two differ the moment one person administers two accounts: every event they produce carries the same actor, so a trail scoped by actor returns somebody else’s history.

And it cannot be derived later. The tenant of an action is a fact at the time of the action; membership is a fact now. Read a membership table for it and somebody who joins tenant A today retroactively owns a year of tenant B’s trail, while somebody who leaves takes theirs with them — exactly when it is most wanted.

Stamped on write, or not knowable.

The action code

domain/reason — the same grammar as an error code, and deliberately so.

The taxonomy is federated: each capability names its own actions under its own domain, so one capability’s actions cannot collide with another’s.

Metadata

A JSON object, validated on write and on read.

ESC