Files
agentic-dev/docs/decisions/adr-018-audit-and-compliance.md
Danijel Martinek f77e6ea881 chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo
content deleted, auth-only reference feature, web-next shell, all gates
green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor
library traces, and product naming are curated out; generic template
repairs (coverage provider devDeps, root test:coverage script, live
lint fixes, root-only release-please) are kept. See TEMPLATE.md for
provenance, curation list, and usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 20:40:54 +02:00

5.3 KiB

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 collectionupdate: () => 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 bridgecurrentTraceId() 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 requiredAuditEntry.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

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.