Files
agentic-dev-template/docs/decisions/adr-018-audit-and-compliance.md

4.3 KiB

ADR-018 — Audit Logging & DPA Compliance

Status: Accepted Date: 2026-05-11 Spec: docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md Plan: docs/superpowers/plans/2026-05-11-audit-and-compliance.md 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; 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.