Evidence
Documentation

Version Latest

Client events

How the Editor, the Agents app and the CLI record what a signed-in person did in the same chained audit ledger, with identity from the token, strict schema, idempotency and limits.

The first-party apps call your Govern installation directly. POST /api/v1/audit/client-events lets a signed-in person's app record what that person did (opened a file, started an agent run, ran a command) in the same HMAC-chained audit ledger as everything Govern records itself.

These entries are attested by the app, not proven by the server: they show what the person's first-party app reported, under that person's identity. Anything that must hold is enforced by Govern's own gateways, scopes and the entries Govern writes itself. A modified app can misreport its own events, but it cannot lie about who it is, in which organisation, or in whose name.

Who may call it

  1. A signed-in person. API keys are refused (403 api_key_not_allowed).
  2. With a verified OAuth client: the client_id claim of the access token. A token without one is refused.
  3. That client must be a registered first-party client (oauth2_clients.first_party, which only a super admin can set): 403 client_not_first_party otherwise. This is checked on every request.

Everything about the caller comes from the token: organisation, user, client. A body that names another organisation or user is refused as a whole with 403 not_own_identity, and nothing is recorded.

Request

POST /api/v1/audit/client-events
Authorization: Bearer <access token of a signed-in person>
Content-Type: application/json
X-Keystone-Client: neuralinverse-editor/1.4.0

{"events": [{
  "client_event_id": "0b0e8a2c-5f0a-4f0e-9c52-6a3b9f1e7d10",
  "type": "editor.file.opened",
  "occurred_at": "2026-10-16T11:59:12.431Z",
  "outcome": "success",
  "session_id": "sess_01J8",
  "resource_type": "file",
  "resource_id": "src/main.go",
  "metadata": {"language": "go", "lines": "412"}
}]}
FieldRule
client_event_idRequired. A UUID or ULID, stored in canonical form
typeRequired. A dotted namespace of 2 to 5 segments, each [a-z][a-z0-9_]{0,31}, at most 96 characters, for example editor.file.opened, agents.run.started, cli.command.executed
occurred_atRequired. RFC 3339 with a zone, at most 7 days in the past and 5 minutes in the future
outcomesuccess (default), failure, denied or error
session_idOptional, 1 to 128 characters of A-Za-z0-9._:~-
resource_type, resource_idBoth or neither; type lower case, id at most 256 bytes
metadataOptional object of at most 32 members; values are strings only, at most 512 bytes each and 8 KiB in total

Unknown members are refused, so a client cannot send server-owned fields. Text must be clean UTF-8 without control, format (bidirectional, zero-width) or separator characters, so a value cannot disguise or split an audit line.

Response

200 for every request that was understood, with one result per event in order:

{"results": [
   {"index": 0, "client_event_id": "0b0e8a2c-...", "status": "accepted", "id": "81234", "duplicate": false, "recorded_at": "2026-10-16T12:00:01.2Z"},
   {"index": 1, "client_event_id": "5c1d...", "status": "duplicate", "id": "80990", "duplicate": true, "recorded_at": "2026-10-16T11:58:40Z"},
   {"index": 2, "client_event_id": "9f2e...", "status": "rejected", "duplicate": false,
    "error": {"code": "invalid_type", "message": "..."}}],
 "accepted": 1, "duplicates": 1, "rejected": 1,
 "client": {"id": "ni_0d6b...", "name": "neuralinverse-editor", "version": "1.4.0"}}

A client drops from its buffer every event that is accepted or duplicate, and drops a rejected event (it will never be accepted as sent). Anything other than 200 recorded nothing and is retried unchanged.

StatusCodeMeaning
400invalid_requestNot one JSON object {"events": [...]}
401unauthorizedNo signed-in principal
403api_key_not_allowed, client_not_first_party, not_own_identityAdmission refused
413payload_too_large, batch_too_largeOver the body or batch limit
415content_typeNot application/json
429rate_limited, quota_exceededA minute limit or daily quota; Retry-After given
503dlp_unavailableThe DLP patterns cannot be loaded; nothing is recorded unscanned
500internal_errorThe audit write failed; nothing is recorded

Per-event rejection codes: invalid_event, invalid_client_event_id, invalid_type, invalid_occurred_at, occurred_at_out_of_range, invalid_outcome, invalid_session_id, invalid_resource, invalid_metadata, dlp_blocked.

What is written

One ledger entry per accepted event, in the organisation's chain, so exports, tiering, the immutability trigger and keystone-audit-verify cover it unchanged.

Entry fieldValue
Organisation, actorThe token's organisation and user, actor type user
actionclient. followed by the type, always; no client can produce an action Govern writes itself
resource_type, resource_idclient_event and the event id
outcomesuccess, or failure for failure, denied and error (the finer value is kept in metadata)
TimeThe server's time of recording; the client's claim is kept as occurred_at
metadatasource: "client", the verified oauth_client_id, the declared name and version, the event id, type, claimed time, outcome, session, resource, and the scrubbed attributes

Find them with GET /api/v1/audit-logs?action_prefix=client..

Idempotency

The idempotency key is a hash of (OAuth client, user, event id), scoped to the organisation. A retry, in any spelling of the same id, returns the original entry's id and time and is not appended again. The same id from another user, client or organisation is a different event and never reveals or overwrites another person's record.

POST /api/v1/agents/audit has the same guarantee for agents when a report carries metadata.client_event_id.

DLP

Event text is scanned with the organisation's DLP patterns before it is written. A secret in a metadata value is redacted in place and counted (dlp_redactions); a finding in session_id or resource_id rejects that event with dlp_blocked.

Limits

Every event of a request counts when it is received, accepted or not. Counters live in PostgreSQL, so every replica sees the same count.

LimitDefaultVariable
Events per request100NI_AUTH_CLIENT_EVENTS_MAX_BATCH
Request body256 KiBNI_AUTH_CLIENT_EVENTS_MAX_BODY_BYTES
Oldest occurred_at7 daysNI_AUTH_CLIENT_EVENTS_MAX_AGE
Furthest occurred_at ahead5 minutesNI_AUTH_CLIENT_EVENTS_MAX_FUTURE
Events per user per UTC minute600NI_AUTH_CLIENT_EVENTS_USER_PER_MINUTE
Events per organisation per UTC minute6,000NI_AUTH_CLIENT_EVENTS_ORG_PER_MINUTE
Events per user per UTC day50,000NI_AUTH_CLIENT_EVENTS_USER_PER_DAY
Events per organisation per UTC day2,000,000NI_AUTH_CLIENT_EVENTS_ORG_PER_DAY

The server refuses to start when a value is unparsable or inconsistent (for example a per-minute user limit below one full batch). The limits are per person, not per app.

Last reviewed 17 October 2026View as Markdown

Client events · Docs · Neural Inverse