Audit trail
Query the append-only audit trail over HTTP — the record a regulatory question is answered from.
GET /v1/audit returns the vault's audit trail: a complete inventory of what was
read, written and changed, with only the field values withheld. It is reserved for
operator principals, and it is never itself recorded — reading the trail leaves no
row in it.
GET /v1/audit
Rows come back newest first, cursor-paged. Filter with any combination of:
| Filter | Selects |
|---|---|
collection | rows naming a field of that collection |
field | rows naming that field |
action | rows of that action (for example read_plaintext, record_deleted) |
record | rows naming that record, by its identifier |
principal | rows whose actor is that principal, by slug |
device | rows naming that device, by its identifier |
since, until | a time window; each is a duration (30d, 12h, 45m) read as that far before now, or an ISO 8601 timestamp |
limit, cursor | the page size and where to resume |
GET /v1/audit?since=30d&collection=policyholders
Authorization: Bearer <operator token>{
"audit_events": [
{
"principal": { "slug": "liberty-agent", "kind": "service" },
"record": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
"field": { "name": "premium", "collection": "policies" },
"action": "read_token",
"grant": 5012,
"device": null,
"context": {},
"sample_rate": 1.0,
"occurred_at": "2026-08-27T14:11:07Z"
}
],
"page": {
"limit": 100,
"max_limit": 100,
"next_cursor": "MjAyNi0wOC0yN1QxNDoxMTowNy4wMDAwMDBafDkxODM="
}
}Reading each row:
recordis the record's identifier (ornullfor a row that names no record);fieldnames the field and its collection (ornullfor a lifecycle row).grantis the identifier of the grant that permitted a read (ornullfor a write or a lifecycle row); it resolves even after the grant is revoked.device, when present, is always labelled"asserted_by_caller": true: the caller named the device, and the trail does not claim it proved the caller held it.sample_rateis a fraction in(0, 1].1.0means every such read was recorded; a smaller value means this one row stands for several, because token and masked reads are sampled.
Paging
next_cursor is null on the last page. Pass it back as cursor to fetch the next
page. limit above max_limit is served at the cap rather than refused, and the
response names the cap, so you know a large page was clamped.
The audit context and its limit
Any audited request may attach a small context — a note of the unit of work the
request belongs to — that is recorded on every row that request writes. You attach it
with request headers on the call that reads or writes the data, not on GET /v1/audit
itself:
| Header | Recorded as | Is |
|---|---|---|
X-Custodian-Lease | context.lease | your own identifier for the unit of work |
X-Custodian-Task | context.task | your own identifier for the task it serves |
X-Custodian-Device | the row's device, marked asserted_by_caller | the device you are acting on |
The server also adds the request_id and source_address it observes for itself, so
a row's context holds whatever of these headers you sent plus those two. A header you
do not send leaves its key out rather than recording a null. These header names are
the defaults; the operator running the server can change them.
An X-Custodian-Device value that does not name a live device of your own vault
refuses the read or write request — naming the identifier to fix — rather than
recording a device you did not prove you hold.
The context is recorded in an append-only table nobody can prune, so a context larger
than the configured limit (the default is 4096 bytes of its JSON form) is refused
with audit_context_too_large, naming the limit and the size supplied, so the request
fails rather than landing a truncated row:
{
"code": "audit_context_too_large",
"message": "...",
"details": { "limit": 4096, "supplied": 8192 }
}A caller that is not an operator is refused with caller_not_operator.