Audit log
Every state-changing operation in PLUR Enterprise writes an audit row. Reads are also audited at a coarser grain (count-and-aggregate, not per-call) to keep volume bounded.
Schema
Section titled “Schema”audit_log table (Postgres):
| Column | Notes |
|---|---|
id | bigserial PK |
ts | timestamp, indexed |
org | org identifier |
actor | JSON: `{type: “user" |
action | string, e.g. engram.create, user.disable, webhook.fire |
target | JSON: what was acted on (engram id, user id, scope, …) |
channel | mcp · rest · admin · scim · webhook · system |
outcome | success · failure · denied |
payload | JSONB: action-specific details |
ip | source IP (hashed if PLUR_AUDIT_HASH_IPS=true) |
user_agent | client UA |
request_id | correlation ID across log lines |
writeAudit() (src/audit/write.ts) is never-throws — audit failures don’t break the request, but they log to stderr.
What’s audited
Section titled “What’s audited”| Category | Actions |
|---|---|
| Auth | auth.login, auth.logout, auth.failed, auth.token.issued, auth.token.revoked |
| Engrams | engram.create, engram.update, engram.retire, engram.pin, engram.unpin |
| Episodes | episode.create, episode.export |
| Packs | pack.install, pack.uninstall, pack.export |
| Sessions | session.start, session.end |
| Users | user.create, user.update, user.disable, user.delete |
| Groups | group.create, group.update, group.member.add, group.member.remove |
| API keys | apikey.issue, apikey.rotate, apikey.revoke |
| SCIM | scim.user.create, scim.user.update, scim.user.disable, scim.group.* |
| Admin | admin.role.assign, admin.scope.create, admin.settings.update |
| Webhooks | webhook.register, webhook.fire, webhook.disable |
Engram reads are aggregated nightly into audit_aggregate rather than logged per-call, to control volume.
Querying
Section titled “Querying”From the admin dashboard
Section titled “From the admin dashboard”/admin/audit — filter by date range, actor, action, channel, outcome. Export CSV or JSON for retention archives.
From SQL
Section titled “From SQL”The table is straightforward Postgres; use whatever client you’d use against any DB:
SELECT ts, actor->>'name' AS who, action, target, outcomeFROM audit_logWHERE ts > NOW() - INTERVAL '7 days' AND outcome = 'denied'ORDER BY ts DESC;From the REST API
Section titled “From the REST API”curl -H "Authorization: Bearer $ADMIN_TOKEN" \ "https://plur.your-org.com/api/v1/admin/audit?from=2026-05-01&channel=scim"From webhooks
Section titled “From webhooks”Subscribe to the audit.entry event for streaming delivery. Be aware: high-traffic orgs can generate hundreds of audit rows per minute — make sure your endpoint can keep up.
Retention
Section titled “Retention”Default: 365 days. Configure with PLUR_AUDIT_RETENTION_DAYS. A nightly job purges rows older than that.
For compliance scenarios that require longer retention (3 years, 7 years), set retention to 0 (no purge) and rely on backups for archival. Audit volume on a mid-sized org is roughly 10–100 MB/month; storing several years is cheap.
What’s NOT audited
Section titled “What’s NOT audited”- The contents of engrams themselves on reads (only counts).
- Webhook delivery payloads beyond event ID and outcome.
- LLM-side activity (PLUR doesn’t run the LLM).
If you need fuller capture (every recall query, every inject task), enable verbose audit with PLUR_AUDIT_VERBOSE=true. This dramatically increases volume — plan storage accordingly.
Hashing IPs
Section titled “Hashing IPs”For GDPR-conscious deployments, set PLUR_AUDIT_HASH_IPS=true. PLUR stores HMAC-SHA256(ip, salt) instead of the raw IP, with the salt rotated quarterly. You can still detect repeat sources within a quarter; you can’t reverse the hash.
Failed audit writes
Section titled “Failed audit writes”If Postgres is briefly unavailable, audit writes go to a local fallback log (/var/lib/plur/audit-fallback.jsonl) and replay when the DB is back. The request itself completes normally — auditing is best-effort by design, but the fallback ensures no rows are silently lost in transient outages.