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.
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
| Field | Type | Description |
|---|---|---|
id | string | Public entry ID (audit_ prefix). |
project_id | string, nullable | The project the action targeted. |
principal_type | string, nullable | user or api_key. Null on platform-originated entries. |
principal_id | string, nullable | Public id of the credential. Null on platform-originated entries — see below. |
action | string | The permission-action string that authorized the request. |
resource_srn | string, nullable | The resource name it was authorized against; type-level on creates, since the resource did not exist yet. |
resource_public_id | string, nullable | The target's public id — from the resource name, or from the response body on a create. |
status | integer | HTTP status of the response, recorded after the change committed. |
request_id | string, nullable | Correlation id, also returned in the X-Request-Id response header. |
ip | string, nullable | Client IP as the runtime saw it. |
user_agent | string, nullable | Request User-Agent as the runtime saw it. |
detail | object, nullable | Extra payload — including additional_checks where a request authorized more than once. |
created_at | string (date-time) | Entries are immutable, so there is no updated_at. |
Key Concepts
What this log does and does not cover
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,blockedortripwire), carrying the whole evaluation record: governing version, resolved class, decision, guard outcome and context snapshot. Plainexecuteevaluations 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
- CLI
- SDK
- curl
naturali list-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--action secrets:DeleteSecret \
--limit 20
const { data: entries } = await naturali.auditLog.listAuditEntries({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { action: 'secrets:DeleteSecret', limit: 20 },
});
for (const entry of entries?.data ?? []) {
console.log(entry.created_at, entry.resource_public_id, entry.status);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/audit-log?action=secrets:DeleteSecret&limit=20" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Read the history of one resource
- CLI
- SDK
- curl
naturali list-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--resource-public-id sec_V1StGXR8Z5jdHi6B
const { data: entries } = await naturali.auditLog.listAuditEntries({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { resource_public_id: 'sec_V1StGXR8Z5jdHi6B' },
});
for (const entry of entries?.data ?? []) {
console.log(entry.action, entry.status, entry.request_id);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/audit-log?resource_public_id=sec_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Read one entry in full
- CLI
- SDK
- curl
naturali get-audit-entry \
--project-id proj_V1StGXR8Z5jdHi6B \
--entry-id audit_V1StGXR8Z5jdHi6B
const { data: entry } = await naturali.auditLog.getAuditEntry({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
entry_id: 'audit_V1StGXR8Z5jdHi6B',
},
});
console.log(entry?.action, entry?.resource_srn, entry?.detail);
curl https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/audit-log/audit_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN"
Archive a month before it ages out
- CLI
- SDK
- curl
naturali export-audit-entries \
--project-id proj_V1StGXR8Z5jdHi6B \
--from 2026-07-01T00:00:00Z \
--to 2026-08-01T00:00:00Z > audit-2026-07.ndjson
const { data: stream } = await naturali.auditLog.exportAuditEntries({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { from: '2026-07-01T00:00:00Z', to: '2026-08-01T00:00:00Z' },
});
// One JSON object per line, oldest first.
for (const line of String(stream).split('\n').filter(Boolean)) {
console.log(JSON.parse(line).action);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/audit-log/export?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-o audit-2026-07.ndjson