Audit log
Every privileged action recorded in the platform writes a row to
audit_log. The table is append-only at the database level (a trigger
refuses UPDATE and DELETE), so the audit trail is tamper-evident even
to operators with database access.
What gets audited
Section titled “What gets audited”| Action | When |
|---|---|
workspace.create | POST /workspaces |
workspace.update | PATCH /workspaces/:slug |
site.create | POST /workspaces/:slug/sites |
site.delete | DELETE /workspaces/:slug/sites/:id |
key.create | POST /workspaces/:slug/keys |
key.revoke | DELETE /workspaces/:slug/keys/:keyId |
dlq.replay | POST /sites/:siteId/dlq/:eventId/replay |
gdpr.export | POST /sites/:siteId/gdpr/export |
gdpr.delete | POST /sites/:siteId/gdpr/delete |
Read-only analytics queries are NOT audited — auditing every
GET /sites/:siteId/pageviews would push the audit table to the same
volume as the events table itself. Privileged mutations and GDPR access
are the v1.0 audit set.
GET /workspaces/:slug/audit?limit=&cursor=Authorization: Bearer <admin-token-or-admin-api-key>| Param | Default | Range |
|---|---|---|
limit | 100 | 1–1000 |
cursor | NOW | ISO-8601 timestamp; rows with ts < cursor are returned |
Response (200):
{ "entries": [ { "id": "<uuid>", "workspace_id": "<uuid>", "actor_kind": "admin" | "apiKey" | "system", "actor_id": "<uuid>" | null, "action": "site.create", "target": "site_marketing" | null, "metadata": { /* free-form JSONB */ }, "ts": "2026-05-02T14:00:00.000Z" } ], "next_cursor": "<iso>" | null}Pagination is cursor-based with descending timestamp — the most recent entry is page 1.
Append-only enforcement
Section titled “Append-only enforcement”The audit_log table has a BEFORE UPDATE and BEFORE DELETE trigger
that raises an error. Even a superuser cannot mutate rows through normal
DML. This is by design — the audit log is a compliance artifact, not a
mutable table.
If an operator needs to purge old rows for storage reasons, they must
either disable the trigger (which is an audited action in itself, when
the platform expands the audit set), or rotate to cold storage via
COPY audit_log TO ….
PII in the audit
Section titled “PII in the audit”The platform records subject ids in audit entries for GDPR actions, but SHA-256-truncates them to 12 hex characters before they land in the row. This prevents the audit log from being its own GDPR violation while still giving operators enough fingerprint to correlate audit entries with external incident data.
Append latency
Section titled “Append latency”Every privileged endpoint emits its audit row inside the same database transaction as the underlying mutation. There is no separate “fire and forget” audit pipeline — if the mutation commits, the audit row is on disk; if the mutation fails, the audit row is rolled back.
Stability
Section titled “Stability”The actor_kind enum (admin / apiKey / system) is part of the
contract. The action enum is closed-set for v1.x — every value
listed in API_CONTRACT §3.4 is guaranteed. New action
values may be added (per the versioning policy),
existing ones won’t change meaning.
The free-form metadata JSONB is operator-facing and may evolve per
action; operators should treat unknown keys as expected-future-fields and
not break on them.