Skip to main content

Audit Log

An append-only record of what was asked of the runtime, and what the answer was.

Overview​

Every mutating request the runtime authorizes leaves one entry: the action that authorized it, the resource it targeted, the HTTP status it got, and a request id you can correlate against. Refusals are recorded too — a 403 is often the entry a review is looking for.

The vocabulary is the runtime's own permission registry: action is the permission string that authorized the request (secrets:DeleteSecret), and resource_srn is what it was authorized against. Reads are not recorded; see what this log does and does not cover, which is the section to read first if you are here for compliance.

The API is read-only: list with filters, fetch one entry, or stream the lot as newline-delimited JSON before the retention window closes.

This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.

Included from the Business plan

The audit log is part of the Business plan and above. On a project whose billing owner is on a lower rung, every route here — reads included — answers 403 plan_feature_not_included with the plan and the feature in details, (no formation resource type declares an audit entry — the runtime writes them). The plan is the project owner's, not the caller's.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Audit Log.

Data Model​

AuditEntry​

FieldTypeDescription
idstringPublic entry ID (audit_ prefix).
project_idstring, nullableThe project the action targeted.
principal_typestring, nullableuser or api_key. Null on platform-originated entries.
principal_idstring, nullablePublic id of the credential. Null on platform-originated entries — see below.
actionstringThe permission-action string that authorized the request.
resource_srnstring, nullableThe resource name it was authorized against; type-level on creates, since the resource did not exist yet.
resource_public_idstring, nullableThe target's public id — from the resource name, or from the response body on a create.
statusintegerHTTP status of the response, recorded after the change committed.
request_idstring, nullableCorrelation id, also returned in the X-Request-Id response header.
ipstring, nullableClient IP as the runtime saw it.
user_agentstring, nullableRequest User-Agent as the runtime saw it.
detailobject, nullableExtra payload — including additional_checks where a request authorized more than once.
created_atstring (date-time)Entries are immutable, so there is no updated_at.

Key Concepts​

What this log does and does not cover​

Every entry names this API's credential, not the person behind it

naturali holds exactly one runtime credential per project, and every call it forwards uses it. So principal_id identifies the project, not the member who acted: two admins doing two different things produce entries that are indistinguishable by principal. Use request_id, action, resource_public_id and created_at to tell entries apart; ip and user_agent describe this API's call to the runtime, not the original client.

What is also absent: anything that never reaches the runtime. Signing in, inviting or removing members, creating and rotating nat_sk_… keys, project creation and deletion — those are naturali's own, and no entry appears here for them.

Within those bounds the log is exactly what it claims: an append-only, tamper-resistant record of the runtime-side changes made for your project, with refusals included.

Reads are not recorded​

The log records mutations. The runtime can record reads too, but the switch is a field on its project resource, and naturali's project is its own — so there is no way to turn read auditing on through this API today. Treat this log as a record of change, and use activity for what agents did and traces for how a generation ran.

One entry per request, even when it authorized twice​

Some routes check more than one permission — binding a trigger to a target checks both the trigger's create permission and the target's start permission. That is still one entry: on success the primary action is the route-level check, and on a 403 the primary is the denied check, because labelling the entry with an earlier allowed action would misattribute the refusal. The rest are kept under detail.additional_checks, each an { action, resource, allowed } object, so no decision is lost.

Entries no principal asked for​

Two things the runtime does on its own also land here, with principal_type and principal_id null rather than an invented principal. There is no "null principal" filter — find them by their action:

  • quotas:MonitorBreach — a monitor-mode quota went over its limit. Written once per window, carrying the metric, window, limit and observed value. Cap a project's spend reads them before enforcing a cap.
  • guardrails:Evaluate — a guardrail evaluation that changed a call's outcome (route_to_approval, blocked or tripwire), carrying the whole evaluation record: governing version, resolved class, decision, guard outcome and context snapshot. Plain execute evaluations are not audited — they are high-volume telemetry, and they changed nothing.

That second one is the reason a guardrail decision is reviewable long after the fact, and it is the pairing worth knowing: the guardrails module tells you what a document would decide, and this log tells you what it did.

Filtering by resource name​

resource_srn is a prefix match, so srn:{project}:secret: narrows to one resource type and a full name narrows to one resource. The log is append-only, so an entry old enough to predate an upstream prefix rename keeps its original name — an srn: prefix matches those too, and history stays reachable.

Append-only, with a retention window​

Entries are never updated or deleted through this API, and a sweep upstream prunes rows past the retention window. Anything you need to keep beyond it has to leave first, which is what the export is for.

The NDJSON export​

GET /v1/projects/{project_id}/audit-log/export streams entries as newline-delimited JSON — one entry object per line, oldest first, the same fields the list endpoint returns, and the same filters. It is deliberately unbounded: there is no limit, because the point is archival.

Two things follow from that. It is a stream, so treat it as bytes: write it to a file or pipe it onward rather than parsing it as one JSON document. And it is not exposed as an MCP tool — an unbounded stream has no useful tool result, so an agent reading the audit log should page GET /v1/projects/{project_id}/audit-log instead.

Examples​

Find out who deleted things, and whether they were allowed to​

naturali list-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--action secrets:DeleteSecret \
--limit 20

Read the history of one resource​

naturali list-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--resource-public-id sec_V1StGXR8Z5jdHi6B

Read one entry in full​

naturali get-audit-entry \
--project-id proj_V1StGXR8Z5jdHi6B \
--entry-id audit_V1StGXR8Z5jdHi6B

Archive a month before it ages out​

naturali export-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--from 2026-07-01T00:00:00Z \
--to 2026-08-01T00:00:00Z > audit-2026-07.ndjson