Activity audit
The append-only audit log — how Pact records every state-changing action, and how to search, filter, and export it for compliance.
Activity audit
Every state-changing action in Pact writes an immutable audit row. Connecting an X account, revealing a stored credential, pivoting into another user's view, running a GDPR erasure, promoting a personalization variant — each lands as one append-only event, tenant-scoped and attributed to the actor who clicked.
The audit log is the platform's system-of-record. It is backed by the
domain_events table and exposed read-only through GET /v1/audit-log.
Live and enforced
The audit surface is real, tenant-isolated, and enforced. Every query carries
tenant_id = :tid, so a leaked identifier can never surface another tenant's
history. Rows are append-only — there is no update or delete path.
What a row looks like
Audit events are written through the single canonical entry point
core.events.write_audit_event. Each row captures:
action— a snake_case dotted verb:modules.patch,viewing_as.pivot,social.account.connected,credential.revealed,persona.set.actor_id— who clicked, asuser:<id>(orsystemfor automated jobs).effective_user_id— when an admin acts under an impersonated view, the audit row records both the actor and the effective user. Search surfaces use the effective id for "who was this done as?" and the actor id for "who clicked?".target_type/target_id, an optional JSON payload, acorrelation_id, and anoccurred_attimestamp.
The write is transaction-aware: the audit row commits atomically with the mutation it records, under the route's own open connection. If the mutation rolls back, so does its audit row — the log never drifts from reality.
Searching the log
GET /v1/audit-log returns a paginated, filtered list. Filtering is rich:
| Query param | Effect |
|---|---|
actor | Exact actor_id match |
action | Exact event_type match |
action_prefix | Prefix match — e.g. all auth.* or scim.* events |
entity_type / entity_id | Scope to one aggregate type or record |
q | Free-text search (LIKE, with metacharacters escaped) |
success | Split "things that worked" from failures |
from / to | occurred_at range |
The success filter is derived, not stored — it classifies an event as a
failure when its event_type ends in .failed, .failure, .denied,
.error, or .blocked (e.g. auth.login.failed, scim.user.denied,
encryption.decrypt.failure). No writer has to set a separate boolean.
curl -s "https://api.pact.place/v1/audit-log?action_prefix=auth&success=false&limit=50" \
-H "Authorization: Bearer $PACT_API_KEY"
Pagination and polling
Paging uses a keyset cursor over (occurred_at, id), not OFFSET. On an
append-only log that grows under heavy write load, offset pagination degrades
quadratically as new rows arrive; the keyset cursor stays flat. The response
weaves the result-set fingerprint into an ETag, so a compliance console can
poll cheaply with If-None-Match and get a 304 when nothing changed.
Two companion endpoints power filter comboboxes: GET /v1/audit-log/actors
(distinct actors) and GET /v1/audit-log/actions (distinct event types).
Exporting
GET /v1/audit-log/export?format=csv|jsonl|xlsx streams the full filtered set
for an auditor. The export is capped at 100,000 rows — comfortably a full
quarter for the largest design partner — so a misconfigured client cannot pull
the entire event log into memory. Larger pulls should page through the list
endpoint.
Where it appears in the app
The read API also powers the admin security console's audit-log page. Some
higher-level admin search filters (module chips, saved searches) layer on top
of the same domain_events store via GET /v1/admin/audit.