# Audit logging & DPA compliance — Design **Date:** 2026-05-11 **Status:** Draft (pending user review) **Companion ADR:** ADR-018 (created in Phase 6). **Builds on:** ADR-014 (instrumentation interfaces — R31-R51 carry over), ADR-017 (OTel migration — provides `currentTraceId()` helper used by the OTel correlation bridge). **Compliance reference:** DPA "Logging & Monitoring" requirements. --- ## 1. Context and motivation DPA compliance mandates audit logging for every personal-data access event: VIEW / CREATE / UPDATE / DELETE / EXPORT / PERMISSION_CHANGE. Each entry must record who (user id, never email/name), what (action + collection + record id + tenant id), when (ISO 8601), and from where (IP truncated to /24 IPv4 or /48 IPv6, plus user agent). Logs must be immutable (append-only, separate access controls), deletable on GDPR erasure request, and ship to centralized aggregation (stdout-only is insufficient). Critically, the contract must prevent categories of mistakes: passwords, OTP codes, reset tokens, MFA secrets, full user objects, and document contents must NEVER be logged. This is enforced by construction — `AuditEntry` has no `payload`/`body`/`oldValue`/`newValue` fields. The observability stack (Sentry → OTel from ADR-017) is the wrong fit for this: observability data is sampled, debugging-oriented, short-retention. Audit data is lossless, compliance-grade, long-retention with a privileged erasure path. The two channels share field names (timestamp, actor, action, correlation id) but have different durability contracts and access controls. This spec ships `@repo/core-audit` as the 5th optional package (joining realtime, events, trpc, ui), with the `AuditLogProtocol` in must-have `core-shared`. Feature code calls `ctx.auditLog?.record(entry)` without importing the optional package. The OTel `correlationId` field links audit entries to traces for compliance auditors to pivot from "who accessed record X" to "what was the request flow." ## 2. Decision summary 1. **`AuditLogProtocol` in `core-shared/di/bind-protocols.ts`** — minimal `{ record(entry): Promise }`. Features depend on `core-shared`, never on optional `@repo/core-audit`. `IAuditLog` (in the optional package) extends the protocol with the privileged `eraseSubject(actorId, mode)`. 2. **`AuditEntry` type in `core-shared/src/audit/audit-entry.ts`** — closed action enum, required `tenant` field (single-tenant projects use `"default"`), required `containsPii` flag, no payload-shaped fields by construction. 3. **`truncateIp(raw)` helper in `core-shared/src/audit/truncate-ip.ts`** — enforces /24 IPv4 + /48 IPv6 truncation; throws on malformed input. 4. **`BindContext.auditLog?` field** — 5th generic `Audit extends AuditLogProtocol = AuditLogProtocol`. Backward-compatible with existing 4-generic callers. 5. **`@repo/core-audit` optional package** — `IAuditLog`, `NoopAuditLog`, `PayloadAuditLog` (local cache; append-only collection), `StdoutJsonAuditLog` (structured JSON to stdout for log-shipper consumption), `MultiSinkAuditLog` (fan-out wrapper), `RecordingAuditLog` (in `core-testing`). 6. **Append-only Payload collection** — `update: () => false` access rule; erasure uses `overrideAccess: true` for privileged ops. 7. **GDPR erasure plumbing** — `eraseSubject(actorId, mode: "pseudonymize" | "delete")`. Pseudonymize replaces actorId with `erased-{sha256(salt+id)[0:16]}`. Salt from `AUDIT_PSEUDONYM_SALT` env (validated in production binding). 8. **Erasure trigger surface** — admin tRPC procedure (`audit.eraseSubject`), Payload `afterDelete` hook factory (`createAuditErasureHook`), auth integration via printed generator next-steps (NOT auto-installed). 9. **OTel correlation bridge** — `currentTraceId()` helper in `core-shared/instrumentation/otel/`; `TraceIdEnrichingAuditLog` decorator at bind time auto-populates `AuditEntry.correlationId` from the active OTel span; explicit caller value wins. 10. **VIEW capture via BOTH patterns** — use-case `record()` calls (developer decides per-read-path) AND `createAuditAfterReadHook` Payload hook factory (opt-in per-collection automatic capture). Fire-and-forget semantics for hooks; sink failures emit structured errors to stderr. 11. **IP/UA explicit at call sites** — no `AsyncLocalStorage`. Callers gather IP+UA from the request, call `truncateIp()`, pass into `record({ from: { ipTruncated, userAgent } })`. Sentinels for non-HTTP context: `"system"` / `"background-job"`. 12. **Six-phase delivery** matching the established cadence (events, realtime, core-package, OTel). ## 3. Architecture overview | Phase | Ships | Verification | |---|---|---| | 1 | `AuditLogProtocol` + `AuditEntry` type + `truncateIp` helper in `core-shared`; `BindContext.auditLog?` field; no impl | Type-only changes; existing tests pass; `BindContext` backward-compatible | | 2 | `@repo/core-audit` package with 4 impls (Noop, Payload, StdoutJson, MultiSink); `RecordingAuditLog` in `core-testing`; `bindAudit` binder; `auditLogs` Payload collection definition | Each impl unit-tested; fan-out asserts settle-all + stderr error path | | 3 | GDPR erasure: `eraseSubject` impls, `pseudonymize` helper, admin tRPC procedure (`audit.eraseSubject`), `createAuditErasureHook` factory | Erase tests: pseudonymize replaces actorId; delete removes entries; tRPC admin-guarded | | 4 | OTel correlation: `currentTraceId()` helper; `TraceIdEnrichingAuditLog` decorator applied at `bindAudit` time | Tests assert traceId auto-populated inside `withSpan`; explicit caller value preserved | | 5 | VIEW capture: `createAuditAfterReadHook` factory; reference wiring documented (NOT auto-installed in auth) | Hook tests: VIEW entries created per read; `shouldSkip` respected; fire-and-forget semantics | | 6 | ADR-018; `docs/guides/audit-and-compliance.md`; generator template at `turbo/generators/templates/core-package/audit/`; byte-identical snapshot + e2e test; CLAUDE.md / AGENTS.md / template-tiers / README / data-flow-explainer / scaffolding-doc updates | All gates green; e2e byte-identical reconstruction passes | ## 4. Phase 1 — Protocol + AuditEntry type ### 4.1 `AuditLogProtocol` In `packages/core-shared/src/di/bind-protocols.ts`: ```ts export type AuditLogProtocol = { record(entry: AuditEntry): Promise; }; ``` `eraseSubject` is NOT on the protocol — it's a privileged operation that only the full `IAuditLog` interface (in `@repo/core-audit`) exposes. Feature binders see only `record`; admin-path code that needs erasure imports `IAuditLog` directly. ### 4.2 `AuditEntry` type In `packages/core-shared/src/audit/audit-entry.ts`: ```ts export type AuditAction = | "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"; export type AuditFrom = { ipTruncated: string; userAgent: string; }; export type AuditEntry = { // WHO actorId: string; actorType: "user" | "system" | "service"; actorRoles: string[]; // snapshot at time of action // WHAT action: AuditAction; resource: { type: string; id?: string }; changedFields?: string[]; // UPDATE only; field names, not values // WHEN at: Date; // SCOPE (where) scope: { feature: string; environment: string; tenant: string; // REQUIRED; "default" for single-tenant }; // WHY (reason + context) reason?: string; correlationId?: string; // OTel trace ID; auto-populated by bind-time decorator requestId?: string; // FROM (per DPA) from: AuditFrom; // PII CLASSIFICATION containsPii: boolean; /** * Free-form list of PII categories present in the audited resource. * Conventions (suggested, not enforced): "email", "name", "phone", * "address", "ssn", "financial", "health". Free-form rather than * closed enum because regulatory classifications differ by jurisdiction * and the caller knows the resource shape best. */ piiCategories?: string[]; // OUTCOME outcome: "success" | "denied" | "error"; errorCode?: string; }; ``` ### 4.3 `truncateIp` helper In `packages/core-shared/src/audit/truncate-ip.ts`: ```ts /** * IPv4: "192.168.1.42" → "192.168.1.0" (/24) * IPv6: "2001:0db8:1234:5678:..." → "2001:db8:1234::" (/48) * Throws on malformed input — compliance regimes prefer hard failure * over partial scrubbing. */ export function truncateIp(raw: string): string { if (raw.includes(":")) { const parts = raw.toLowerCase().split(":").filter((p) => p !== ""); if (parts.length < 3) { throw new Error(`truncateIp: malformed IPv6 address "${raw}"`); } return `${parts[0]}:${parts[1]}:${parts[2]}::`; } const parts = raw.split("."); if (parts.length !== 4 || parts.some((p) => isNaN(Number(p)))) { throw new Error(`truncateIp: malformed IPv4 address "${raw}"`); } return `${parts[0]}.${parts[1]}.${parts[2]}.0`; } ``` ### 4.4 `BindContext` extension 5th generic param: ```ts export type BindContext< Bus extends EventBusProtocol = EventBusProtocol, Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, Metrics extends MetricsProtocol = MetricsProtocol, Audit extends AuditLogProtocol = AuditLogProtocol, > = BindContextBase & { bus?: Bus; queue?: IJobQueue; realtime?: Realtime; realtimeRegistry?: RealtimeReg; metrics?: Metrics; auditLog?: Audit; }; ``` `BindProductionContext` extended in parallel. ### 4.5 Phase 1 files - Create: `packages/core-shared/src/audit/audit-entry.ts` (+ test) - Create: `packages/core-shared/src/audit/truncate-ip.ts` (+ test) - Create: `packages/core-shared/src/audit/index.ts` (barrel) - Modify: `packages/core-shared/src/di/bind-protocols.ts` (add `AuditLogProtocol`) - Modify: `packages/core-shared/src/di/bind-context.ts` (add 5th generic + `auditLog?` field) - Modify: `packages/core-shared/src/index.ts` (re-export from `./audit`) - Modify: `packages/core-shared/package.json` (subpath export `./audit`) ## 5. Phase 2 — `@repo/core-audit` package with impls ### 5.1 Package structure Mirrors core-events / core-realtime / core-trpc / core-ui shape: ``` packages/core-audit/ ├── AGENTS.md ├── eslint.config.js ├── package.json ├── tsconfig.json ├── turbo.json ├── vitest.config.ts └── src/ ├── index.ts # Barrel ├── audit-log.interface.ts # IAuditLog extends AuditLogProtocol ├── audit-logs-collection.ts # Payload collection definition ├── noop-audit-log.ts # + .test.ts ├── payload-audit-log.ts # + .test.ts ├── stdout-json-audit-log.ts # + .test.ts ├── multi-sink-audit-log.ts # + .test.ts ├── di/ │ ├── bind-audit.ts # + .test.ts │ └── symbols.ts # AUDIT_SYMBOLS.IAuditLog ├── integrations/ │ └── api/ # (populated in Phase 3) └── hooks/ # (populated in Phase 3 and Phase 5) ``` ### 5.2 `IAuditLog` ```ts import type { AuditLogProtocol, AuditEntry } from "@repo/core-shared/audit"; export interface IAuditLog extends AuditLogProtocol { // record(entry: AuditEntry) inherited eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise; } ``` ### 5.3 `auditLogs` Payload collection Append-only by config (`update: () => false`); admin-only read and delete. Field set: - `actorId`, `actorType`, `actorRoles` (json), `action` (enum), `resourceType`, `resourceId` - `changedFields` (json), `scopeFeature`, `scopeEnvironment`, `scopeTenant` - `reason`, `correlationId`, `requestId`, `ipTruncated`, `userAgent` - `containsPii` (checkbox), `piiCategories` (json), `outcome` (enum), `errorCode` - Payload's built-in `createdAt` maps to `AuditEntry.at` Indexed columns: `actorId`, `action`, `resourceType`, `scopeFeature`, `scopeTenant`, `correlationId`. ### 5.4 Impls - **`NoopAuditLog`** — both methods no-op. - **`PayloadAuditLog`** — calls `payload.create({ collection: "audit-logs", data })`. `eraseSubject` impl lands in Phase 3. - **`StdoutJsonAuditLog`** — `process.stdout.write(JSON.stringify({...entry, at: entry.at.toISOString(), _type: "audit"}) + "\n")`. Erasure emits a `_type: "audit-erasure"` tombstone (best-effort; past lines can't be retroactively removed; downstream aggregator handles real erasure). - **`MultiSinkAuditLog`** — `Promise.allSettled` over inner sinks; failures emit `_type: "audit-sink-error"` to stderr (avoids recursion via Sentry/OTel). - **`RecordingAuditLog`** (in `core-testing`) — collects `recorded[]` and `erasures[]`. Mirrors Payload semantics in `eraseSubject` for test fidelity. ### 5.5 `bindAudit` binder ```ts export function bindAudit( container: Container, opts: { payloadConfig?: SanitizedConfig; sinks?: ("payload" | "stdout")[] } = {}, ): { auditLog: IAuditLog } { const sinkList = opts.sinks ?? ["payload", "stdout"]; const sinks: IAuditLog[] = []; if (sinkList.includes("payload") && opts.payloadConfig) { sinks.push(new PayloadAuditLog(opts.payloadConfig)); } if (sinkList.includes("stdout")) { sinks.push(new StdoutJsonAuditLog()); } const inner = sinks.length > 1 ? new MultiSinkAuditLog(sinks) : sinks.length === 1 ? sinks[0]! : new NoopAuditLog(); const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner); // wrapper from Phase 4 container.bind(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog); return { auditLog }; } ``` Note: `TraceIdEnrichingAuditLog` is added in Phase 4. In Phase 2, the binder returns the inner sink directly; Phase 4 introduces the wrapper. ## 6. Phase 3 — GDPR erasure plumbing ### 6.1 `pseudonymize` helper ```ts import { createHash } from "node:crypto"; export function pseudonymize(actorId: string): string { const salt = process.env.AUDIT_PSEUDONYM_SALT ?? "dev-fallback-salt-replace-in-prod"; const hash = createHash("sha256").update(salt + ":" + actorId).digest("hex"); return `erased-${hash.slice(0, 16)}`; } ``` Production-binding code (in `bindAudit`) validates `process.env.AUDIT_PSEUDONYM_SALT` is set when `NODE_ENV === "production"`; throws at boot if absent. ### 6.2 `PayloadAuditLog.eraseSubject` impl - Mode `"delete"`: `payload.delete({ collection: "audit-logs", where: { actorId: { equals } }, overrideAccess: true })`. - Mode `"pseudonymize"`: fetch matching docs (`payload.find` with `overrideAccess: true`), iterate and `payload.update({ id, data: { actorId: pseudonym }, overrideAccess: true })`. Bypasses `update: () => false` via the documented Payload escape hatch. `StdoutJsonAuditLog.eraseSubject` emits the tombstone (described in Phase 2). ### 6.3 `createAuditErasureHook` Payload hook factory ```ts export type AuditErasureHookOpts = { auditLog: IAuditLog; mode?: "pseudonymize" | "delete"; }; export function createAuditErasureHook(opts: AuditErasureHookOpts): CollectionAfterDeleteHook { const mode = opts.mode ?? "pseudonymize"; return async ({ doc }) => { if (typeof doc.id === "string" || typeof doc.id === "number") { await opts.auditLog.eraseSubject(String(doc.id), mode); } }; } ``` Hook factory has no schema-specific knowledge — works on any collection with an `id` field. ### 6.4 Admin tRPC procedure ```ts // packages/core-audit/src/integrations/api/router.ts export const auditRouter = t.router({ eraseSubject: auditProcedure .input(z.object({ actorId: z.string().min(1), mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"), }).strict()) .mutation(async ({ input, ctx }) => { if (!ctx.auditLog) throw new Error("Audit not bound"); await ctx.auditLog.eraseSubject(input.actorId, input.mode); return { ok: true as const }; }), }); export type AuditRouter = typeof auditRouter; ``` `auditProcedure` (in `procedures.ts`) uses an `adminOnly` middleware that throws `TRPCError({ code: "FORBIDDEN" })` if the user lacks the admin role. ### 6.5 Phase 3 files - Create: `packages/core-audit/src/pseudonymize.ts` (+ test) - Modify: `packages/core-audit/src/payload-audit-log.ts` (eraseSubject impl) - Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test) - Create: `packages/core-audit/src/hooks/index.ts` (barrel) - Create: `packages/core-audit/src/integrations/api/procedures.ts` - Create: `packages/core-audit/src/integrations/api/router.ts` (+ test) - Modify: `packages/core-audit/src/index.ts` (re-exports) - Modify: `packages/core-audit/package.json` (subpath exports `./api`, `./hooks`) ## 7. Phase 4 — OTel correlation bridge ### 7.1 `currentTraceId()` helper ```ts // packages/core-shared/src/instrumentation/otel/current-trace-id.ts import { trace } from "@opentelemetry/api"; export function currentTraceId(): string | undefined { const span = trace.getActiveSpan(); if (!span) return undefined; const ctx = span.spanContext(); if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined; return ctx.traceId; } ``` Re-exported from `core-shared/instrumentation` barrel. ### 7.2 `TraceIdEnrichingAuditLog` decorator ```ts import { currentTraceId } from "@repo/core-shared/instrumentation"; export class TraceIdEnrichingAuditLog implements IAuditLog { constructor(private readonly inner: IAuditLog) {} async record(entry: AuditEntry): Promise { if (entry.correlationId) return this.inner.record(entry); const traceId = currentTraceId(); if (!traceId) return this.inner.record(entry); return this.inner.record({ ...entry, correlationId: traceId }); } eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { return this.inner.eraseSubject(actorId, mode); } } ``` Applied at bind time (Phase 2's `bindAudit` is updated in Phase 4 to wrap the inner sink/fan-out). Single source of truth for trace correlation; sinks see entries that already have `correlationId` populated. ### 7.3 Phase 4 files - Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test) - Modify: `packages/core-shared/src/instrumentation/otel/index.ts` (re-export) - Modify: `packages/core-shared/src/instrumentation/index.ts` (re-export) - Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test) - Modify: `packages/core-audit/src/di/bind-audit.ts` (wrap inner with `TraceIdEnrichingAuditLog`) - Modify: `packages/core-audit/src/index.ts` (re-export) ## 8. Phase 5 — VIEW capture wiring ### 8.1 `createAuditAfterReadHook` factory Full signature in §6 above. Key semantics: - **Fire-and-forget**: `void auditLog.record(entry).catch(...)` — a failing audit sink MUST NEVER break a user-facing read. - **Sentinel IP/UA values for non-HTTP context**: `"internal"` / `"payload-internal"` (Payload reads from background jobs, admin UI). - **Required `containsPii` + `resourceType` at hook-creation time** — declared per-collection, propagated to every entry. - **`shouldSkip` predicate** — opt-out for high-traffic non-sensitive reads. ### 8.2 Reference wiring (NOT auto-installed) The auth feature's `bind-production.ts` does NOT import from `@repo/core-audit` directly out of the box. The hook is installed via dynamic import in a bind-time block that the generator's next-steps print as a diff to apply: ```ts if (ctx.auditLog) { const { createAuditAfterReadHook, createAuditErasureHook } = await import("@repo/core-audit/hooks"); // ... install on users collection } ``` This keeps `core-audit` truly optional — auth has no hard module-load dependency on it. ### 8.3 Phase 5 files - Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test) - Modify: `packages/core-audit/src/hooks/index.ts` (re-export new factory) - Modify: `packages/core-audit/src/index.ts` (re-export) ## 9. Phase 6 — ADR-018 + generator template + docs ### 9.1 ADR-018 Status Accepted; date 2026-05-11; cites spec + plan paths; 12 decision points (the decision-summary list above). Supersedes nothing; relates to ADR-014 (interface decisions R31-R51 carry over), ADR-015 (events/jobs — no overlap), ADR-017 (OTel migration — provides `currentTraceId()`). ### 9.2 Generator template Capture all `packages/core-audit/` files as `.hbs` templates under `turbo/generators/templates/core-package/audit/`. Generate byte-identical snapshot at `turbo/generators/__snapshots__/core-package/audit.snapshot.json`. e2e test at `turbo/generators/__tests__/core-package-audit.e2e.test.ts` (mirrors the existing per-package e2e pattern). Add `audit` to the `core-package` generator's `choices` list and `CORE_PACKAGE_GENERATORS` dispatch table: ```ts CORE_PACKAGE_GENERATORS["audit"] = () => [ () => assertOptionalPackageNotPresent("core-audit"), ...emitTemplateTree("core-package/audit", "packages/core-audit"), () => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"), () => printAuditNextSteps(), ]; ``` `printAuditNextSteps()` prints the 7-step manual wiring guide (env salt, collection mount, tRPC mount, app aggregator bind, auth-hook diff, log-shipper setup, verify gates). ### 9.3 Docs - **ADR-018** (per above) - **`docs/guides/audit-and-compliance.md`** — full how-to guide. Sections: 1. What DPA requires (summary of the compliance doc) 2. Two-pattern model (use-case calls vs Payload hooks) 3. When to use which pattern 4. Wiring core-audit into your app (mirrors the print) 5. Sample Vector + Fluent Bit configs (stdout → Grafana Loki EU) 6. GDPR erasure via admin tRPC 7. Sample-week audit verification ("who accessed any given record") 8. Hostile-actor immutability test 9. Common mistakes - **`docs/architecture/template-tiers.md`** — add `core-audit` row to the optional table - **`docs/scaffolding/core-package-generator.md`** — add `audit` row to the templates table - **`CLAUDE.md`** — Project Overview lists 5 optionals (realtime/events/trpc/ui/audit); "Read first" adds `docs/guides/audit-and-compliance.md` with prerequisite note - **`AGENTS.md`** — package tags list adds `@repo/core-audit` (optional); generator list mentions audit alongside the existing four - **`docs/architecture/data-flow-explainer.html`** — audit-layer arrows marked conditional/optional - **`README.md`** — Optional packages block adds `pnpm turbo gen core-package audit` ### 9.4 Phase 6 files (counts) - Create: 1 ADR, 1 guide, ~12 generator template files (`audit/**`), 1 snapshot JSON, 1 e2e test - Modify: `turbo/generators/config.ts`, `template-tiers.md`, `core-package-generator.md`, `CLAUDE.md`, `AGENTS.md`, `data-flow-explainer.html`, `README.md` ## 10. Testing strategy **Per-phase unit tests** — each new file gets a Vitest test. Type-level assertions for `AuditEntry` shape. Recording test double in `core-testing` follows the existing pattern. **Phase 6 e2e test** — byte-identical reconstruction of `@repo/core-audit` from the generator template; runs `pnpm install + lint + typecheck + test + boundaries` against the reconstructed workspace. Mirrors the four existing per-package e2e tests. **Manual smoke (post-Phase 6)** — scaffold core-audit into a workspace, wire the 7 manual next-steps, trigger sign-in (CREATE entry), profile read (VIEW entry), deletion (DELETE + erasure). Verify entries land in the Payload `auditLogs` collection AND on stdout as structured JSON. **Verification gates per phase** — `pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries` all green. No phase commits with regressions. ## 11. ESLint allowlist No new rules. `packages/core-audit/` matches the `packages/core-*` wildcard in `boundaries/elements` (in `core-eslint/base.js`) so it's automatically classified as a `core` package. The "what NOT to log" enforcement is type-level (no `payload`/`body`/`oldValue`/`newValue` fields on `AuditEntry`), not lint-level. ## 12. Out of scope - **Per-collection UPDATE value capture.** v1 captures field NAMES only (`changedFields`). Value capture (for non-sensitive fields) is a future opt-in mechanism (per-collection allowlist of field names → values captured in a separate `valueDiff` field). - **External-aggregator HTTP sinks** (LokiAuditLog, DatadogAuditLog, AxiomAuditLog). v1 ships stdout JSON + a Payload local cache. Direct vendor sinks land when a project's log-shipper infrastructure is insufficient. Architecture supports them — `IAuditLog` extension only. - **OTel Logs API integration.** Audit is a parallel channel, NOT a signal flowing through OTel (different durability + retention contract). The `correlationId` field is the only bridge. - **Browser-side audit logging.** Phase scope is server-only (mirrors ADR-017's server-only OTel migration). Browser-emitted audit entries (e.g., page view of a sensitive document) require a future spec. - **Audit log replay / forensic timeline UI.** Compliance auditors query via Payload admin (`auditLogs` collection) or the external aggregator (Grafana/Loki/Datadog UI). A dedicated in-app forensic UI is future work. - **Retention policy automation.** v1 documents the 90-day-hot / 1-year-archive policy in the guide. Automation (background job that prunes old entries) is a follow-up spec. - **`ObservableGauge`-style audit "current state" probes.** Audit captures events, not state snapshots. ## 13. Related - ADR-014 — instrumentation interfaces (R31-R51 carry over, especially R36 "user id only, never email/name") - ADR-015 — events and jobs (no overlap; audit is observational, events are reactive) - ADR-017 — OTel migration (provides `currentTraceId()` helper used by the OTel bridge) - DPA compliance doc (user-provided; the source of truth for action enum + required fields + retention + immutability requirements)