Audit events
Written in the same transaction as the change they describe. An approval that left no trace is worse than an approval that did not happen, so the audit write failing fails the action. Actions are dotted and past tense —kyc.review.approved, api_key.revoked,
settings.updated. GET /api/v1/admin/events/types returns the live vocabulary, so a
filter cannot drift from what is actually written.
Each event records the actor (Islem <admin:…>, stable across renames), the entity it acted
on, the originating IP and user agent, and a small metadata object. The metadata holds
decisions and identifiers only — a name or a document number there would turn the audit
log into a second, browsable copy of the data it audits.
Request logs
Every API request, minus health probes and static assets. Each row carries the method, path, status, latency, IP, user agent, the API key prefix, and:actor— who made the call. An admin JWT carries its subject’s id and name in its claims, so this is resolved in-process with no database hit. An API key contributes only its non-secret prefix; the owning business is resolved by joining on that prefix at read time, which keeps a lookup off the hot path.requestBody/responseBody— redacted, and often deliberately absent.
What happens to bodies
Capture is defended twice, and both defences are conservative on purpose.- A path deny-list. Endpoints whose purpose is releasing applicant identity —
/kyc/applications/{id}and its report routes,/compliance/screen,/kyc/webhook— store no body at all, in either direction. Redaction is a filter, and a filter is the wrong tool when the entire payload is the sensitive thing. - Key redaction everywhere else. Any field whose name matches a sensitive key is
replaced with
[redacted]at every depth, including inside arrays. That covers applicant identity (surname, document number, birth date, contact details), credentials (passwords, API keys, tokens, signatures), and capability URLs — a KYClinkUrlcarries a session token that lets its holder open that applicant’s capture page, so it is a bearer credential that happens to look like a link.
Content-Length is missing or above 32 KB are not buffered at all — they stream straight to
the handler and are simply not logged.
An empty body in the dashboard is normal, not a bug. The drawer says which of the four
reasons applies: identity-bearing path, empty payload, capture switched off, or unparseable.
“No body” and “body withheld” lead an operator to different conclusions.
Retention
Retention is an operator setting, not a redeploy. It lives inplatform_settings and is
edited under Settings → Platform:
A background worker wakes every five minutes, re-reads these, and prunes once the interval
has elapsed. It ticks faster than it prunes on purpose: the obvious
sleep(interval); prune()
loop means an operator who shortens the interval from a week to an hour waits up to a week
for that to take effect, because the loop is already asleep on the old value.
The lower bound of 1 day is deliberate. A retention of 0 would mean “delete everything on the
next tick” — a destructive action nobody should be able to take by mistyping a number in a
form.
Clearing by hand
DELETE /api/v1/admin/request-logs deletes immediately, optionally scoped with
?olderThanDays=N. It requires settings:manage — deleting operational history is a
settings-level action, not a read.
The deletion is recorded in the audit trail before it runs, in the same transaction. The
record of the clear therefore survives the clear, which is the whole reason the two tables
have different retention.
Permissions
settings:manage belongs to full admins only. It does not follow developers:manage
around, because the ability to shorten retention to a day is the ability to make an incident
un-investigable.