Querying the trail

Two routes, and nothing else

RouteScope
GET /audit/eventsaudit:events:read
GET /audit/events/:eventIdaudit:events:read_detail

Both are reads. The trail is append-only: no route deletes, edits, or prunes — because a management credential that could erase an audit row could erase the evidence of its own use.

Two scopes, not one, because the two reads disclose different things

The listing answers who did what, when, and whether it worked. Its projection carries no network identifier and no capability payload.

The single-event read additionally returns ip, userAgent and metadata — the trail’s personal data, and the bag capabilities write email addresses and resource names into.

Bulk-harvesting those is a privacy incident, so it takes a grant the adopter makes deliberately.

And because they are separate routes: a credential holding only the detail scope cannot enumerate the trail to find ids to read, and a credential holding only the listing scope cannot resolve one.

Scope matching is exact — no prefix, no wildcard. Both names, and what each one confers, are constants of this capability.

Pagination is keyset, never offset

The trail** is appended to while it is being read.** An offset page silently skips records — and on a security trail that is a record you never see, not a cosmetic glitch.

A page returns nextCursor; pass it back as ?cursor=. A malformed cursor is a first page, not an error.

The filter

Every field optional, all ANDed. An empty filter returns the whole trail, newest first.

actorType, actorIdWho acted
actionAn exact domain/reason code
outcome, severity
resourceType, resourceIdWhat was targeted
project, environment, workerWhere it was recorded
tenantWhose account it was done to
from, toInclusive bounds on when

tenant: null is a filter, not an absence

This is the one field where null means something.

OmittedDo not filter by tenant
nullThe events that belong to no tenant

A CLI-originated action, a fleet-wide operator action, a row recorded before the column existed — all genuinely have no tenant, and show me those is a question adopters ask.

Without it, asking that question means writing SQL against this capability’s own table.

Reading is audited, including the reads that found nothing

Reading the record of everyone else’s actions is itself a security-relevant action.

audit/trail_read records the filter and how much came back. audit/event_read records which event was asked for.

Yes, that appends to the table it just read. A surface that exempted itself from the guarantee it provides would be worth less than the row it saves — and since nothing prunes the table yet, a read-heavy dashboard is a growth decision as much as a security one.

It is a Kysely query first

The typed read seam is the primary interface; the control-plane routes are a surface over it. It is this package’s own contribution behind a scope guard, rather than something core reaches in and adds.

Which means your own code can query the trail directly, with the same filter type, without going through HTTP at all — the reference has the filter’s fields and the row it returns.

ESC