Every administrative action — a role change, an SSO configuration edit, a credential rotation, a policy update, a login, an API key created or revoked — is appended to identity_audit_events, a table with three properties most application tables do not have.

Append-only, at the database level

A trigger rejects UPDATE unconditionally, and rejects DELETE of any row whose organization still exists — the one lawful exception is the cascade that runs when the organization itself is deleted (see Delete an organization below), which the trigger recognizes because the parent row is already gone by the time it fires. This is not enforced by the API or the service layer — those never expose an edit or delete path either — it is enforced by PostgreSQL itself, so a bug, a support script or an operator with a psql prompt cannot quietly rewrite or remove a live tenant’s history.

Hash-chained, so a rewrite is detectable

Each row carries chain_seq (a per-tenant sequence) and entry_hash, a SHA-256 digest of the row’s own fields and the previous row’s hash, computed by the same trigger that enforces append-only. Deleting or editing a row at the database level — the one thing the trigger cannot see, because it only fires on INSERT — breaks the chain: every row after the gap no longer hashes forward correctly, and verification reports exactly where.
A break reports ok: false, the sequence number where it broke, and why. The one lawful way rows leave the chain is organization deletion, which leaves a tombstone recording how many rows were erased and the chain head at the moment of erasure — an operator (or an auditor) can tell “erased because the tenant closed its account” apart from “someone made rows disappear.” The chain is computed and verified inside the database with an unkeyed digest: it detects accidental and most unprivileged tampering, but a database owner able to disable the trigger and recompute every hash could produce an internally-consistent forged chain. Anchoring chain heads outside the database (a signed, periodically published checkpoint) would close that gap and is not built — do not describe the chain as tamper-proof against a database owner, only tamper-evident against everything short of one.

What every row carries

Action, resource, and actor — attributed three ways at once when it applies: the human who delegated (actor_user_id), the non-human principal that acted (actor_workload_id), and the process name, so “a workload acted under this person’s authority” is one row, not two that have to be joined by guessing. And where it came from: the client address (as TRUSTED_PROXIES resolves it — see Deployment), the User-Agent, and the request ID that joins the row to the access log.

Retention

Audit rows are never deleted by the retention job, at any age, regardless of plan. finding history and usage metering expire on a plan’s window; identity_audit_events does not, because the schema’s append-only trigger would refuse the delete in any case, and because a compliance evidence trail that quietly aged out would be a second, worse surprise. There is currently no separate configurable retention window for the audit trail beyond “kept for the life of the tenant” — if your framework requires a documented, shorter retention with destruction, that is a data-handling policy to state in your own documentation, not a knob TRUSTIVAN exposes today.

Getting it out: pull, or subscribe

Pull. GET /api/v1/audit/export?format=jsonl|csv streams the trail, filtered by since/until, with the chain verified fail-closed before the first byte is written: if verification cannot complete, the export is refused rather than shipped with an unverified chain header. Requires identity:read and the audit_export entitlement. See API reference. Subscribe. audit.recorded is a webhook event — see Webhooks — that fires once per appended row, for a SIEM or log pipeline that wants the trail pushed rather than polled. It uses the same entitlement as the pull export, and it is deliberately narrower: no actor identity (only a kind — user, api_key, workload, system, scim), and none of the row’s free-form detail/before/after fields, which have carried policy bodies and credential names. What travels is enough to correlate “what happened, from where, and does it match this access-log line” — a receiver that needs the acting identity for one event pulls that row from the export using the delivery’s chain_seq.

See also