Custodian
ReferenceHTTP API

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:

FilterSelects
collectionrows naming a field of that collection
fieldrows naming that field
actionrows of that action (for example read_plaintext, record_deleted)
recordrows naming that record, by its identifier
principalrows whose actor is that principal, by slug
devicerows naming that device, by its identifier
since, untila time window; each is a duration (30d, 12h, 45m) read as that far before now, or an ISO 8601 timestamp
limit, cursorthe 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:

  • record is the record's identifier (or null for a row that names no record); field names the field and its collection (or null for a lifecycle row).
  • grant is the identifier of the grant that permitted a read (or null for 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_rate is a fraction in (0, 1]. 1.0 means 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:

HeaderRecorded asIs
X-Custodian-Leasecontext.leaseyour own identifier for the unit of work
X-Custodian-Taskcontext.taskyour own identifier for the task it serves
X-Custodian-Devicethe row's device, marked asserted_by_callerthe 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.

On this page