PPactDocs
Productivity

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, as user:<id> (or system for 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, a correlation_id, and an occurred_at timestamp.

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 paramEffect
actorExact actor_id match
actionExact event_type match
action_prefixPrefix match — e.g. all auth.* or scim.* events
entity_type / entity_idScope to one aggregate type or record
qFree-text search (LIKE, with metacharacters escaped)
successSplit "things that worked" from failures
from / tooccurred_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.

bash
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.