Govern writes every change and every enforced decision to one append-only audit log. Changes are written in the same database transaction as the change itself; if the audit entry cannot be written, the change does not happen.
What makes it tamper-evident
- Hash chains. Each organisation has its own chain of entries. Every entry carries an HMAC-SHA256 hash over its content and the previous entry's hash, keyed with the deployment's
NI_AUTH_AUDIT_HMAC_KEY. Removing, reordering or changing an entry breaks the chain. - Lanes. For throughput, an organisation's chain runs in several lanes (8 by default); each lane is a chain of its own and entries within a lane are totally ordered. An organisation that needs one order for every entry can set one lane with
PUT /api/v1/audit-logs/chain-lanes(Owner and Admin, signed-in users only). The change applies at the next epoch seal and never reorders existing entries. - Signed epochs and checkpoints. Every minute, the heads of the chains that moved are sealed into an epoch: a Merkle root, linked to the previous epoch and signed with the audit signing key (Ed25519). Every five minutes a signed Merkle checkpoint is made over the new entries. Someone holding the HMAC key therefore cannot rewrite one organisation's chain without breaking the signed record.
- Immutable rows. A database trigger refuses any update or delete of audit rows, with one exception: the verified move of old entries to the cold tier (below).
- Region. Every entry records the region (cell) that wrote it inside its hash.
The public keys that verify signed checkpoints, exports and cold segments are published at /.well-known/keystone-audit-keys.json. The signing keys are sealed under KMS_MASTER_KEY, so keep that key stable.
In the console
The Audit service has three pages: the log itself (Audit log on a self-hosted installation, Audit trail on the hosted service) with a detail view of each record, Integrity for chain verification, and SIEM export. Audit records from other services are linked from it, among them Agent decisions and the approvals Decision history. Reading the log needs audit:read.
Filters
GET /api/v1/audit-logs and GET /api/v1/organizations/{id}/audit-logs take:
| Parameter | Meaning |
|---|---|
action | One action, for example agentpolicy.rule.created |
action_prefix | Actions starting with it, for example models.: 1 to 64 characters of [a-z0-9._-] ending with .; not together with action |
resource_type | The kind of object changed |
outcome | success or failure |
project_id | A project of your organisation |
from, to | RFC 3339 times |
limit, offset | Paging |
Every non-GET API request is recorded with its action, outcome, request id, source IP, user agent, route, response status and duration. Request bodies, credentials and tokens are never captured.
Reading and verifying
| Route | Permission | What |
|---|---|---|
GET /api/v1/audit-logs | audit:read | List and filter the organisation's entries (hot tier) |
GET /api/v1/audit-logs/verify | audit:read | Verify the chain over a range; longer ranges run as verification jobs. Each run is itself audited. |
GET /api/v1/audit-logs/query | audit:read | Query both tiers (below) |
POST /api/v1/audit-logs/export | audit:create | Start an export |
GET /api/v1/audit-logs/exports/{exportID}/download | audit:read | Download a finished export |
Query
GET /api/v1/audit-logs/query?from=&to=&query= runs a pipeline over one organisation's entries in both the hot and cold tiers:
fields @timestamp, action, outcome
| filter action like /^models\./ and outcome = "failure"
| stats count(*) as failures by action
| sort failures desc
| limit 20
Commands are fields, filter, stats ... by, sort and limit. A query is refused whole, never answered partially, when it would read more than 2,000,000 entries or 1 GiB of cold objects, or span more than 366 days.
Signed exports
An export is a queued NDJSON or CSV file of a time window, with a manifest signed with the audit signing key (Ed25519) that can be checked offline. Exports read both tiers, so moving entries to the cold tier never removes them from an organisation's export of its own records.
Offline verification
keystone-audit-verify verifies a chain without network access or a connection to Govern, for air-gapped installations and external auditors.
keystone-audit-verify verify \
--chain audit_chain.json \
--tree-heads tree_heads.json \
--public-key keystone.pub \
[--hmac-key KEY] \
[--output report.json]
keystone-audit-verify cold \
--dir ./audit-bucket \
--keys keystone-audit-keys.json \
[--hmac-key KEY] [--org ORG_ID | --platform-entries] [--hot chain.json] \
[--output report.json]
verify checks an exported chain; with the HMAC key it also re-derives every entry hash. cold checks cold-tier segments: the manifest signatures, every chain link, every object's hash and the link from each segment to the next, and with --hot the boundary between the cold tier and the live database. An organisation can download a bundle of its own segments with GET /api/v1/audit-logs/cold-segments/{seq}/bundle and prove its rows complete and in the chain without anyone else's data and without the HMAC key.
Retention tiers
| Tier | Where | Protection |
|---|---|---|
| Hot | PostgreSQL | Immutability trigger |
| Cold | An S3 or S3-compatible bucket, as signed Parquet segments | Server-side encryption with your KMS key; the signed manifest detects any change |
| Archive | The same bucket | Object Lock in compliance mode, until the newest entry of the object plus the archive retention |
Without a bucket, the whole audit log stays in PostgreSQL. With tiering configured:
- Entries older than the hot window move to the cold tier. On a self-hosted installation the hot window is
NI_AUTH_AUDIT_HOT_WINDOW_DAYS(default 90) and the archive retention isNI_AUTH_AUDIT_ARCHIVE_YEARS(default 0, at most 10). - Fail toward keeping. A row leaves PostgreSQL only after the object holding it was written, read back and matched, and only while its signed proof is in the bucket. A range whose chain does not verify is never archived; tiering stops with every entry still hot.
- Retention only grows. A longer archive retention extends the Object Lock of existing objects; a shorter one changes nothing.
- Never deleted. Govern has no delete operation on the audit bucket, and its IAM policy needs no
s3:DeleteObject. - Legal holds keep an organisation's entries hot.
- The bucket must have versioning, Object Lock and server-side KMS encryption. Cross-region replication is required unless you set
NI_AUTH_AUDIT_TIERING_REQUIRE_REPLICATION=false. Requests are signed with Signature Version 4 and no cloud SDK, so an on-premises S3-compatible store with Object Lock (for example MinIO) works air-gapped.
| Variable | Default | |
|---|---|---|
NI_AUTH_AUDIT_TIERING_BUCKET | Turns tiering on | |
NI_AUTH_AUDIT_TIERING_KMS_KEY_ID | Required: the server-side encryption key | |
NI_AUTH_AUDIT_TIERING_REGION | AWS_REGION | Bucket region |
NI_AUTH_AUDIT_TIERING_ENDPOINT | AWS S3 in the region | S3-compatible endpoint |
NI_AUTH_AUDIT_TIERING_PATH_STYLE | false | Path-style addressing |
NI_AUTH_AUDIT_TIERING_REQUIRE_REPLICATION | true | Refuse to run without cross-region replication |
NI_AUTH_AUDIT_HOT_WINDOW_DAYS | 90 | Hot window |
NI_AUTH_AUDIT_ARCHIVE_YEARS | 0 | Archive retention |
A server whose log already has entries in the cold tier refuses to start without the bucket, because exports and queries would leave those entries out.
Client events from the Neural Inverse apps
The Editor, the Agents app and the CLI record what a signed-in person does in the same chained log, through POST /api/v1/audit/client-events:
{"events": [{
"client_event_id": "0b0e8a2c-5f0a-4f0e-9c52-6a3b9f1e7d10",
"type": "editor.file.opened",
"occurred_at": "2026-10-16T11:59:12.431Z",
"outcome": "success",
"resource_type": "file",
"resource_id": "src/main.go"
}]}
- Only a person, only through a first-party app. The caller must be a signed-in person whose access token was issued to a registered first-party OAuth client. API keys and other clients get 403.
- Identity from the token. Organisation and user come from the access token. A body that names another organisation or user is refused whole with
403 not_own_identity. - Strict schema. Unknown fields are rejected, so a client cannot set server-owned fields such as hashes or times. The timestamp must fall within bounds of the server's clock.
- DLP. Events are scrubbed with the organisation's DLP patterns; if the patterns cannot be loaded, nothing is recorded (
503 dlp_unavailable). - Idempotent. Each event is recorded once per
client_event_id, and the answer acknowledges each event as accepted, duplicate or rejected. - Limits. Per-person and per-organisation minute limits and daily quotas, set with the
NI_AUTH_CLIENT_EVENTS_*variables, answer 429 withRetry-After.
Not available yet
These audit features are present in the source but recorded as stubs or not mounted, so do not rely on them: the audit retention policy route (/api/v1/audit-logs/retention), the Merkle checkpoint routes under /api/v1/audit-logs/merkle, SIEM forwarding destinations configured from the audit page, and the separate enterprise audit API. Retention is set by the tiering configuration above, and signed checkpoints are made by the server's own job.
Related records
- The agent policy decision log and the AI tool hook decisions are kept as their own records, with targets stored only as hashes. See Agent policy.
- Audit and usage records pulled from third-party AI vendors are kept as vendor events with their own read and export API. See Third-party AI coding tools and Evidence.