Govern
Documentation

Version Latest

Audit log

The tamper-evident audit log of Neural Inverse Govern, with signed exports, offline verification, hot, cold and archive retention tiers, and client events from the Neural Inverse apps.

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:

ParameterMeaning
actionOne action, for example agentpolicy.rule.created
action_prefixActions starting with it, for example models.: 1 to 64 characters of [a-z0-9._-] ending with .; not together with action
resource_typeThe kind of object changed
outcomesuccess or failure
project_idA project of your organisation
from, toRFC 3339 times
limit, offsetPaging

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

RoutePermissionWhat
GET /api/v1/audit-logsaudit:readList and filter the organisation's entries (hot tier)
GET /api/v1/audit-logs/verifyaudit:readVerify the chain over a range; longer ranges run as verification jobs. Each run is itself audited.
GET /api/v1/audit-logs/queryaudit:readQuery both tiers (below)
POST /api/v1/audit-logs/exportaudit:createStart an export
GET /api/v1/audit-logs/exports/{exportID}/downloadaudit:readDownload 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

TierWhereProtection
HotPostgreSQLImmutability trigger
ColdAn S3 or S3-compatible bucket, as signed Parquet segmentsServer-side encryption with your KMS key; the signed manifest detects any change
ArchiveThe same bucketObject 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 is NI_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.
VariableDefault
NI_AUTH_AUDIT_TIERING_BUCKETTurns tiering on
NI_AUTH_AUDIT_TIERING_KMS_KEY_IDRequired: the server-side encryption key
NI_AUTH_AUDIT_TIERING_REGIONAWS_REGIONBucket region
NI_AUTH_AUDIT_TIERING_ENDPOINTAWS S3 in the regionS3-compatible endpoint
NI_AUTH_AUDIT_TIERING_PATH_STYLEfalsePath-style addressing
NI_AUTH_AUDIT_TIERING_REQUIRE_REPLICATIONtrueRefuse to run without cross-region replication
NI_AUTH_AUDIT_HOT_WINDOW_DAYS90Hot window
NI_AUTH_AUDIT_ARCHIVE_YEARS0Archive 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 with Retry-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.

  • 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.

Last reviewed 17 October 2026View as Markdown

Audit log · Docs · Neural Inverse