# ADR-018 — Audit Logging & DPA Compliance **Status:** Accepted **Date:** 2026-05-11 **Companion guide:** docs/guides/audit-and-compliance.md ## Context DPA compliance mandates audit logging for every personal-data access event: VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE, with immutable storage, GDPR-deletable path, centralized aggregation, and strict "what NOT to log" boundaries. The interface decisions from ADR-014 (R31-R51) carry over but audit needs its own channel — observability data is sampled and short-retention, audit data is lossless and long-retention with privileged erasure. ## Decision (12 points) 1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface. Features call `ctx.auditLog?.record(entry)` without importing the optional package. 2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/ EXPORT/PERMISSION_CHANGE/CONSENT_GRANT/CONSENT_WITHDRAW/RESTRICT/UNRESTRICT; new actions require explicit type bump. No payload/body/oldValue/newValue fields — type enforces "what NOT to log". 3. **`@repo/core-audit` as 5th optional package** — joins realtime, events, trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`. 4. **Four impls + Recording test double**: NoopAuditLog, PayloadAuditLog (local cache), StdoutJsonAuditLog (operator ships via Vector/Fluent Bit), MultiSinkAuditLog (fan-out), RecordingAuditLog (core-testing). 5. **Append-only Payload collection** — `update: () => false` access rule is the compliance backbone; erasure path uses `overrideAccess: true`. 6. **GDPR erasure** — sha256-salted pseudonymization (`erased-{hash[0:16]}`) or hard delete. AUDIT_PSEUDONYM_SALT env REQUIRED in production; bind-time validation fails fast. 7. **Erasure trigger surface** — admin tRPC procedure (`audit.eraseSubject`), Payload `afterDelete` hook factory (`createAuditErasureHook`), auth integration via printed generator next-steps (NOT auto-installed). 8. **OTel correlation bridge** — `currentTraceId()` helper in core-shared; `TraceIdEnrichingAuditLog` decorator at bind time auto-populates `AuditEntry.correlationId` from active OTel span. Explicit caller wins. 9. **VIEW capture via BOTH patterns** — use-case `record()` calls (developer decides per-read-path) AND `createAuditAfterReadHook` factory (opt-in per-collection automatic capture). Fire-and-forget for hooks. 10. **IP/UA explicit at call sites** — no AsyncLocalStorage. Callers use `truncateIp(raw)` (/24 IPv4, /48 IPv6) and pass into `record({ from: { ... } })`. Sentinels for non-HTTP context: `"system"` / `"background-job"`. 11. **Multi-tenancy: tenant field required** — `AuditEntry.scope.tenant` non-optional; single-tenant projects pass `"default"`. Forces multi-tenant thinking from day one. 12. **Six-phase delivery** matching established cadence. ## Alternatives considered - **Vendor-coupled SDK (Datadog/Grafana direct)** — rejected; couples to vendor. - **Payload-only sink** — fails compliance (hostile-actor immutability). - **Aggregator-only sink** — fails dev ergonomics. Fan-out is the balance. - **AsyncLocalStorage for request context** — rejected per user preference; explicit > implicit. - **Optional tenant field** — rejected; DPA-aligned scope discipline benefits from forcing the question on every call. ## Consequences **Positive:** - DPA-compliant baseline ships with the optional package. - Vendor-neutral via stdout JSON + log shipper; any aggregator works. - OTel correlation gives compliance auditors one-click pivot to traces. - Type-enforced exclusion of "what not to log" prevents categories of mistakes. **Negative:** - Boilerplate at every record() call site (IP/UA explicit). - core-audit ↔ auth coupling for the user-collection hook is awkward (manual install via generator next-steps). - StdoutJsonAuditLog's eraseSubject is best-effort (tombstone only; past stdout lines can't be retroactively removed). ## Relationship to other ADRs - ADR-014 (instrumentation interfaces): audit is a parallel channel, not a signal flowing through OTel. The correlationId field is the bridge. - ADR-015 (events/jobs): no overlap; audit is observational, events are reactive. - ADR-017 (OTel migration): provides currentTraceId() helper. ## Amendments ### 2026-05-19 — Consent and restriction action types (Epic B, ADR-025) Added four new `AuditAction` values to `core-shared/audit/audit-entry.ts`: | Action | Article | Description | | ------------------ | ------------ | ------------------------------------------------- | | `CONSENT_GRANT` | GDPR Art. 7 | Subject granted consent for a processing purpose | | `CONSENT_WITHDRAW` | GDPR Art. 7 | Subject withdrew consent for a processing purpose | | `RESTRICT` | GDPR Art. 18 | Subject requested restriction of processing | | `UNRESTRICT` | GDPR Art. 18 | Restriction lifted (controller or subject action) | **Reason:** `core-consent` and `core-dsr` optional packages (Story 03 and 06 of Epic B) emit these action types via `core-audit`'s existing `IAuditLog` channel. The values must exist in `core-shared`'s closed enum before either optional core can be implemented. No change to `IAuditLog`'s interface surface — the new values flow through `AuditEntry.action` automatically.