# 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<void> }`. 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<void>;
};
```

`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<void>;
}
```

### 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<IAuditLog>(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<void> {
    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<void> {
    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)
