Files
agentic-dev/docs/decisions/adr-018-audit-and-compliance.md
Danijel Martinek dd339b11b1 feat(core-shared): extend audit action enum with consent and restriction types
Adds CONSENT_GRANT, CONSENT_WITHDRAW, RESTRICT, UNRESTRICT to the
AuditAction closed enum per GDPR Art. 7 and Art. 18 requirements.

core-consent and core-dsr optional cores (Epic B Stories 03/06) emit
these action types via core-audit's IAuditLog channel; the values must
exist in core-shared's enum before either optional core can be built.
No change to IAuditLog's interface surface — new values flow through
AuditEntry.action automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:16:30 +00: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.