diff --git a/docs/superpowers/plans/2026-05-11-audit-and-compliance.md b/docs/superpowers/plans/2026-05-11-audit-and-compliance.md new file mode 100644 index 0000000..566a3cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-audit-and-compliance.md @@ -0,0 +1,3259 @@ +# Audit Logging & DPA Compliance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@repo/core-audit` as the 5th optional core package, with `AuditLogProtocol` + `AuditEntry` type in must-have `core-shared`. Provides DPA-compliant audit logging: closed action enum (VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE), append-only Payload collection, structured JSON stdout sink, OTel trace correlation, GDPR erasure (pseudonymize/delete), and Payload `afterRead` + `afterDelete` hook factories. + +**Architecture:** Six sequential phases. Phase 1 ships the universal kernel in core-shared (no impl). Phase 2 ships the optional package with all impls + binder. Phase 3 adds GDPR erasure plumbing (admin tRPC + Payload hook factory). Phase 4 adds the OTel correlation bridge. Phase 5 adds the `afterRead` hook factory for opt-in automatic VIEW capture. Phase 6 publishes ADR-018, the audit-and-compliance guide, and the generator template (so `pnpm turbo gen core-package audit` becomes available). + +**Tech Stack:** TypeScript, Node 22, Vitest, Payload CMS, tRPC, OpenTelemetry API (`@opentelemetry/api`), Node crypto (sha256). + +**Spec:** `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md` — read first, especially §4 (Phase 1), §5 (Phase 2 package + impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture). + +**Phase numbering:** Plan uses `Phase 0 (Read first)` as preamble; work phases 1-6 map 1:1 to spec phases. + +--- + +## Phase 0 — Read first + +- [ ] **Step 1: Read the spec end-to-end** + +Open `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md`. Pay close attention to §2 (12 decision points), §4 (`AuditEntry` type), §5.4 (impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture). + +- [ ] **Step 2: Read the DPA compliance reference** + +The user-provided compliance doc in the spec's §1 (and the conversation that produced this plan). The 6 required actions, the 4 required fields (who/what/when/from_where), the immutability requirements, and the "what NOT to log" list are load-bearing. + +- [ ] **Step 3: Skim existing optional package patterns** + +Read the structure of the four existing optional packages in their template form: `turbo/generators/templates/core-package/{realtime,events,trpc,ui}/`. `@repo/core-audit` follows the same shape (package.json, eslint.config.js, tsconfig.json, turbo.json, vitest.config.ts, AGENTS.md, src/). + +- [ ] **Step 4: Skim existing core-shared protocol pattern** + +Read `packages/core-shared/src/di/bind-protocols.ts` end-to-end. `AuditLogProtocol` is added alongside the existing four (EventBus, RealtimeBroadcaster, RealtimeRegistry, Metrics). Same pattern: minimal surface, optional package extends with full interface. + +- [ ] **Step 5: Skim OTel `currentTraceId` callsite** + +The `currentTraceId()` helper used in Phase 4 lives in `packages/core-shared/src/instrumentation/otel/`. The OTel migration (ADR-017) wired the active-span access via `@opentelemetry/api`'s `trace.getActiveSpan()`. Audit just reads from it. + +--- + +## Phase 1 — Protocol + AuditEntry type in core-shared + +**Goal:** Ship the universal kernel surface. `AuditLogProtocol` + `AuditEntry` type + `truncateIp` helper exist in `core-shared`. `BindContext.auditLog?` field added. No impl. + +**Files touched:** + +- Create: `packages/core-shared/src/audit/audit-entry.ts` +- Create: `packages/core-shared/src/audit/audit-entry.test.ts` +- Create: `packages/core-shared/src/audit/truncate-ip.ts` +- Create: `packages/core-shared/src/audit/truncate-ip.test.ts` +- Create: `packages/core-shared/src/audit/index.ts` +- Modify: `packages/core-shared/src/di/bind-protocols.ts` +- Modify: `packages/core-shared/src/di/bind-context.ts` +- Modify: `packages/core-shared/src/index.ts` +- Modify: `packages/core-shared/package.json` + +### Task 1.1: AuditEntry type (TDD) + +**Files:** +- Create: `packages/core-shared/src/audit/audit-entry.ts` +- Create: `packages/core-shared/src/audit/audit-entry.test.ts` + +- [ ] **Step 1: Write the failing type-level test** + +Create `packages/core-shared/src/audit/audit-entry.test.ts`: + +```ts +import { describe, it, expectTypeOf } from "vitest"; +import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; + +describe("AuditAction", () => { + it("is a closed enum of 6 values", () => { + expectTypeOf().toEqualTypeOf< + "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE" + >(); + }); +}); + +describe("AuditFrom", () => { + it("requires ipTruncated and userAgent", () => { + expectTypeOf().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>(); + }); +}); + +describe("AuditEntry", () => { + it("requires the WHO/WHAT/WHEN/SCOPE/FROM/PII/OUTCOME fields", () => { + const entry: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: ["admin"], + action: "VIEW", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", + }; + expectTypeOf(entry).toMatchTypeOf(); + }); + + it("makes optional fields actually optional", () => { + type Entry = AuditEntry; + type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode"; + expectTypeOf>().toEqualTypeOf>>(); + }); +}); +``` + +- [ ] **Step 2: Run test → FAIL** + +```bash +pnpm --filter @repo/core-shared test audit-entry.test +``` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +Create `packages/core-shared/src/audit/audit-entry.ts`: + +```ts +/** + * Closed enum of audited actions per DPA. New action types require an + * explicit type bump — compliance auditors sample by enum value. + */ +export type AuditAction = + | "VIEW" + | "CREATE" + | "UPDATE" + | "DELETE" + | "EXPORT" + | "PERMISSION_CHANGE"; + +/** + * `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6) + * before storage; use `truncateIp(rawIp)` to enforce. For non-HTTP contexts, + * sentinels are conventional: `{ ipTruncated: "system", userAgent: "background-job" }`. + */ +export type AuditFrom = { + ipTruncated: string; + userAgent: string; +}; + +/** + * Universal audit entry. By construction, this type has NO `payload`/`body`/ + * `oldValue`/`newValue` fields — the DPA "what NOT to log" exclusion list is + * enforced by the type itself. UPDATE actions capture field NAMES only + * (`changedFields`); per-collection value capture is a separate API (out of + * scope for v1). + */ +export type AuditEntry = { + // WHO + /** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */ + actorId: string; + actorType: "user" | "system" | "service"; + /** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */ + actorRoles: string[]; + + // WHAT + action: AuditAction; + resource: { type: string; id?: string }; + /** UPDATE only: names of fields that changed (NOT values — PII risk). */ + changedFields?: string[]; + + // WHEN + /** Server time. Sinks serialize as ISO 8601. */ + at: Date; + + // SCOPE (where) + scope: { + feature: string; + environment: string; + /** Required field. Single-tenant projects use "default" as the sentinel. */ + tenant: string; + }; + + // WHY + reason?: string; + /** OTel trace ID. Auto-populated by `TraceIdEnrichingAuditLog` decorator at bind time. */ + correlationId?: string; + requestId?: string; + + // FROM (per DPA) + from: AuditFrom; + + // PII CLASSIFICATION + /** Caller MUST declare. Drives downstream retention/access policies. */ + containsPii: boolean; + /** + * Free-form list. Conventions (suggested, not enforced): "email", "name", + * "phone", "address", "ssn", "financial", "health". Free-form because + * regulatory categories differ by jurisdiction. + */ + piiCategories?: string[]; + + // OUTCOME + outcome: "success" | "denied" | "error"; + errorCode?: string; +}; +``` + +- [ ] **Step 4: Run test → PASS** + +```bash +pnpm --filter @repo/core-shared test audit-entry.test +``` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/audit/audit-entry.ts packages/core-shared/src/audit/audit-entry.test.ts +git commit -m "feat(core-shared): AuditEntry type with closed action enum + required tenant" +``` + +### Task 1.2: truncateIp helper (TDD) + +**Files:** +- Create: `packages/core-shared/src/audit/truncate-ip.ts` +- Create: `packages/core-shared/src/audit/truncate-ip.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `packages/core-shared/src/audit/truncate-ip.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { truncateIp } from "./truncate-ip"; + +describe("truncateIp", () => { + describe("IPv4", () => { + it("truncates to /24 (zeros the last octet)", () => { + expect(truncateIp("192.168.1.42")).toBe("192.168.1.0"); + expect(truncateIp("10.0.0.255")).toBe("10.0.0.0"); + expect(truncateIp("8.8.8.8")).toBe("8.8.8.0"); + }); + + it("throws on malformed input", () => { + expect(() => truncateIp("192.168.1")).toThrow(/malformed IPv4/); + expect(() => truncateIp("192.168.1.foo")).toThrow(/malformed IPv4/); + expect(() => truncateIp("a.b.c.d")).toThrow(/malformed IPv4/); + }); + + it("throws on empty string", () => { + expect(() => truncateIp("")).toThrow(/malformed IPv4/); + }); + }); + + describe("IPv6", () => { + it("truncates to /48 (keeps first 3 hextets)", () => { + expect(truncateIp("2001:0db8:1234:5678:abcd:ef00:1234:5678")).toBe("2001:0db8:1234::"); + expect(truncateIp("2001:0db8:abcd:1234::")).toBe("2001:0db8:abcd::"); + }); + + it("lowercases hextets", () => { + expect(truncateIp("2001:0DB8:ABCD:5678::")).toBe("2001:0db8:abcd::"); + }); + + it("throws on too-few hextets", () => { + expect(() => truncateIp("2001:0db8")).toThrow(/malformed IPv6/); + }); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-shared test truncate-ip.test +``` + +- [ ] **Step 3: Implement** + +Create `packages/core-shared/src/audit/truncate-ip.ts`: + +```ts +/** + * Truncates an IP address per DPA: + * IPv4 → /24 ("192.168.1.42" → "192.168.1.0") + * IPv6 → /48 ("2001:0db8:1234:5678:..." → "2001:0db8:1234::") + * + * Throws on malformed input rather than silently returning the raw value — + * compliance regimes prefer hard failures over partial scrubbing. + */ +export function truncateIp(raw: string): string { + if (raw.includes(":")) { + // IPv6: keep first 3 hextets (48 bits) + 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]}::`; + } + // IPv4: keep first 3 octets (24 bits) + const parts = raw.split("."); + if ( + parts.length !== 4 || + parts.some((p) => p === "" || isNaN(Number(p)) || !/^\d+$/.test(p)) + ) { + throw new Error(`truncateIp: malformed IPv4 address "${raw}"`); + } + return `${parts[0]}.${parts[1]}.${parts[2]}.0`; +} +``` + +- [ ] **Step 4: Run → PASS** + +```bash +pnpm --filter @repo/core-shared test truncate-ip.test +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/audit/truncate-ip.ts packages/core-shared/src/audit/truncate-ip.test.ts +git commit -m "feat(core-shared): truncateIp helper (/24 IPv4, /48 IPv6) per DPA" +``` + +### Task 1.3: AuditLogProtocol + barrel + subpath export + +**Files:** +- Modify: `packages/core-shared/src/di/bind-protocols.ts` +- Create: `packages/core-shared/src/audit/index.ts` +- Modify: `packages/core-shared/src/index.ts` +- Modify: `packages/core-shared/package.json` + +- [ ] **Step 1: Add AuditLogProtocol to bind-protocols.ts** + +Append to `packages/core-shared/src/di/bind-protocols.ts`: + +```ts +import type { AuditEntry } from "../audit/audit-entry"; + +/** + * Minimal audit-log protocol surface. `IAuditLog` (in optional `@repo/core-audit`) + * extends this — typechecks fail if narrowed below. Feature binders that + * receive `ctx.auditLog` see only this protocol type. + * + * `eraseSubject` is NOT on the protocol — it's a privileged op exposed only + * on the full `IAuditLog` interface in the optional package. + */ +export type AuditLogProtocol = { + record(entry: AuditEntry): Promise; +}; +``` + +(The import is OK because `audit/audit-entry.ts` is a sibling file in the same package.) + +- [ ] **Step 2: Create audit barrel** + +Create `packages/core-shared/src/audit/index.ts`: + +```ts +export type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; +export { truncateIp } from "./truncate-ip"; +``` + +- [ ] **Step 3: Add to core-shared root barrel** + +Modify `packages/core-shared/src/index.ts` to add a re-export. Find existing `export * from "./..."` lines and add: + +```ts +export * from "./audit"; +``` + +- [ ] **Step 4: Add subpath export** + +In `packages/core-shared/package.json`, add to `exports` (alphabetically among existing entries): + +```json +"./audit": "./src/audit/index.ts", +``` + +- [ ] **Step 5: Verify** + +```bash +pnpm --filter @repo/core-shared typecheck +pnpm --filter @repo/core-shared test +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-shared/src/di/bind-protocols.ts \ + packages/core-shared/src/audit/index.ts \ + packages/core-shared/src/index.ts \ + packages/core-shared/package.json +git commit -m "feat(core-shared): AuditLogProtocol + ./audit subpath export" +``` + +### Task 1.4: BindContext.auditLog? field (5th generic) + +**Files:** +- Modify: `packages/core-shared/src/di/bind-context.ts` + +- [ ] **Step 1: Add 5th generic + field** + +Read the current `BindContext` declaration. Modify to add `Audit` as the 5th generic and `auditLog?: Audit` as the new field: + +```ts +import type { + EventBusProtocol, + RealtimeBroadcasterProtocol, + RealtimeRegistryProtocol, + MetricsProtocol, + AuditLogProtocol, // <- new +} from "./bind-protocols"; + +// BindContextBase unchanged + +export type BindContext< + Bus extends EventBusProtocol = EventBusProtocol, + Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, + RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, + Metrics extends MetricsProtocol = MetricsProtocol, + Audit extends AuditLogProtocol = AuditLogProtocol, // <- new +> = BindContextBase & { + bus?: Bus; + queue?: IJobQueue; + realtime?: Realtime; + realtimeRegistry?: RealtimeReg; + metrics?: Metrics; + auditLog?: Audit; // <- new +}; + +export type BindProductionContext< + Bus extends EventBusProtocol = EventBusProtocol, + Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, + RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, + Metrics extends MetricsProtocol = MetricsProtocol, + Audit extends AuditLogProtocol = AuditLogProtocol, // <- new +> = BindContext & { + config: SanitizedConfig; +}; +``` + +- [ ] **Step 2: Verify backward compat** + +```bash +pnpm typecheck +``` +Expected: clean. Existing 4-generic callers still resolve because the 5th generic has a default. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-shared/src/di/bind-context.ts +git commit -m "feat(core-shared): BindContext.auditLog? field (5th generic)" +``` + +### Task 1.5: Phase 1 verification gate + +- [ ] **Step 1: Run all gates** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` +Expected: all green. No new lint warnings. + +(No commit; verification gate only.) + +--- + +## Phase 2 — @repo/core-audit package with impls + +**Goal:** Ship the optional package with 4 impls (Noop, Payload, StdoutJson, MultiSink) + `RecordingAuditLog` in core-testing + `bindAudit` binder. Append-only Payload collection definition. + +**Files touched (new package scaffolding):** + +- Create: `packages/core-audit/{AGENTS.md, eslint.config.js, package.json, tsconfig.json, turbo.json, vitest.config.ts}` +- Create: `packages/core-audit/src/{index.ts, audit-log.interface.ts, audit-logs-collection.ts}` +- Create: `packages/core-audit/src/{noop-audit-log.ts, stdout-json-audit-log.ts, payload-audit-log.ts, multi-sink-audit-log.ts}` (+ tests each) +- Create: `packages/core-audit/src/di/{bind-audit.ts, symbols.ts}` (+ bind-audit test) +- Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test) +- Modify: `packages/core-testing/src/instrumentation/index.ts` (re-export) +- Modify: `apps/web-next/next.config.mjs` (`transpilePackages`) +- Modify: `pnpm-lock.yaml` (after `pnpm install`) + +### Task 2.1: Scaffold the package skeleton + +**Files:** All `packages/core-audit/` top-level files. + +- [ ] **Step 1: Inspect an existing optional package for reference** + +```bash +ls turbo/generators/templates/core-package/events/ +cat turbo/generators/templates/core-package/events/package.json.hbs +cat turbo/generators/templates/core-package/events/tsconfig.json.hbs +cat turbo/generators/templates/core-package/events/turbo.json.hbs +cat turbo/generators/templates/core-package/events/vitest.config.ts.hbs +cat turbo/generators/templates/core-package/events/eslint.config.js.hbs +``` + +These are the canonical shapes. Mirror them for core-audit. + +- [ ] **Step 2: Create the directory + scaffolding** + +```bash +mkdir -p packages/core-audit/src/{di,integrations/api,hooks} +``` + +- [ ] **Step 3: package.json** + +Create `packages/core-audit/package.json`: + +```json +{ + "name": "@repo/core-audit", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./collection": "./src/audit-logs-collection.ts", + "./di": "./src/di/bind-audit.ts", + "./hooks": "./src/hooks/index.ts", + "./api": "./src/integrations/api/router.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@repo/core-shared": "workspace:*", + "@trpc/server": "^11.0.0", + "zod": "^3.23.0" + }, + "peerDependencies": { + "payload": "^3.0.0" + }, + "peerDependenciesMeta": { + "payload": { "optional": true } + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "inversify": "^6.2.0", + "payload": "^3.14.0", + "reflect-metadata": "^0.2.2", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 4: tsconfig.json** + +Create `packages/core-audit/tsconfig.json`: + +```json +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 5: turbo.json** + +Create `packages/core-audit/turbo.json`: + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 6: vitest.config.ts** + +Create `packages/core-audit/vitest.config.ts`: + +```ts +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + test: { + setupFiles: ["@repo/core-testing/setup/no-instrumentation"], + }, +}); +``` + +- [ ] **Step 7: eslint.config.js** + +Create `packages/core-audit/eslint.config.js`: + +```js +import base from "@repo/core-eslint/base"; + +export default [...base]; +``` + +- [ ] **Step 8: AGENTS.md** + +Create `packages/core-audit/AGENTS.md`: + +```markdown +# @repo/core-audit + +Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`. + +## Structure + +``` +src/ + audit-log.interface.ts # IAuditLog extends AuditLogProtocol + audit-logs-collection.ts # Payload collection (append-only) + noop-audit-log.ts # NoopAuditLog + payload-audit-log.ts # PayloadAuditLog (local cache impl) + stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink) + multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper) + trace-id-enriching-audit-log.ts # OTel correlation decorator + pseudonymize.ts # sha256-with-salt for GDPR pseudonymization + di/bind-audit.ts # bindAudit binder + integrations/api/router.ts # admin tRPC procedure + hooks/ # Payload hook factories +``` + +## Compliance posture + +- `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log". +- Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path. +- `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time. + +See `docs/guides/audit-and-compliance.md` for the full guide. +``` + +- [ ] **Step 9: Install + verify** + +```bash +pnpm install +pnpm --filter @repo/core-audit typecheck +``` +Expected: no errors. The package exists but has no source files yet. + +- [ ] **Step 10: Add to transpilePackages** + +Modify `apps/web-next/next.config.mjs` — find the `transpilePackages` array and add `"@repo/core-audit"` (alphabetically among existing entries). + +- [ ] **Step 11: Commit** + +```bash +git add packages/core-audit/ apps/web-next/next.config.mjs pnpm-lock.yaml +git commit -m "feat(core-audit): scaffold optional package (no impls yet)" +``` + +### Task 2.2: IAuditLog interface + AUDIT_SYMBOLS + +**Files:** +- Create: `packages/core-audit/src/audit-log.interface.ts` +- Create: `packages/core-audit/src/di/symbols.ts` + +- [ ] **Step 1: Create the interface** + +Create `packages/core-audit/src/audit-log.interface.ts`: + +```ts +import type { AuditLogProtocol, AuditEntry } from "@repo/core-shared/audit"; + +/** + * Full audit log interface. Extends the minimal `AuditLogProtocol` from + * core-shared with the privileged `eraseSubject` op for GDPR erasure. + * + * Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol` + * (record). Admin-path code that needs erasure imports this full interface. + * + * The `extends` link forces typecheck failure if either side narrows below + * the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster, + * IRealtimeHandlerRegistry, IMetrics. + */ +export interface IAuditLog extends AuditLogProtocol { + // record(entry: AuditEntry): Promise — inherited from protocol + eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise; +} + +// Re-export AuditEntry for convenience (so consumers don't always need +// to dual-import from @repo/core-shared/audit). +export type { AuditEntry }; +``` + +- [ ] **Step 2: Create DI symbols** + +Create `packages/core-audit/src/di/symbols.ts`: + +```ts +export const AUDIT_SYMBOLS = { + IAuditLog: Symbol.for("core-audit:IAuditLog"), +} as const; +``` + +- [ ] **Step 3: Verify** + +```bash +pnpm --filter @repo/core-audit typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-audit/src/audit-log.interface.ts packages/core-audit/src/di/symbols.ts +git commit -m "feat(core-audit): IAuditLog interface + AUDIT_SYMBOLS" +``` + +### Task 2.3: NoopAuditLog (TDD) + +**Files:** +- Create: `packages/core-audit/src/noop-audit-log.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/noop-audit-log.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { NoopAuditLog } from "./noop-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +describe("NoopAuditLog", () => { + const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "VIEW", + resource: { type: "articles", id: "1" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", + }; + + it("record() is a no-op that does not throw", async () => { + const log = new NoopAuditLog(); + await expect(log.record(sample)).resolves.toBeUndefined(); + }); + + it("eraseSubject() is a no-op that does not throw", async () => { + const log = new NoopAuditLog(); + await expect(log.eraseSubject("user_1", "pseudonymize")).resolves.toBeUndefined(); + await expect(log.eraseSubject("user_1", "delete")).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-audit test noop-audit-log.test +``` + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/noop-audit-log.ts`: + +```ts +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +export class NoopAuditLog implements IAuditLog { + async record(_entry: AuditEntry): Promise { + // intentional no-op + } + async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { + // intentional no-op + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/noop-audit-log.ts packages/core-audit/src/noop-audit-log.test.ts +git commit -m "feat(core-audit): NoopAuditLog impl" +``` + +### Task 2.4: StdoutJsonAuditLog (TDD) + +**Files:** +- Create: `packages/core-audit/src/stdout-json-audit-log.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/stdout-json-audit-log.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StdoutJsonAuditLog } from "./stdout-json-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: ["admin"], + action: "CREATE", + resource: { type: "articles", id: "abc" }, + at: new Date("2026-05-11T10:00:00.000Z"), + scope: { feature: "blog", environment: "production", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, + containsPii: false, + outcome: "success", +}; + +describe("StdoutJsonAuditLog", () => { + let writeSpy: ReturnType; + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + it("record() writes one JSON line per entry to stdout", async () => { + const log = new StdoutJsonAuditLog(); + await log.record(sample); + expect(writeSpy).toHaveBeenCalledOnce(); + const written = writeSpy.mock.calls[0]![0] as string; + expect(written.endsWith("\n")).toBe(true); + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit"); + expect(parsed.actorId).toBe("user_1"); + expect(parsed.action).toBe("CREATE"); + expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization + }); + + it("eraseSubject() emits a tombstone with mode + actorId", async () => { + const log = new StdoutJsonAuditLog(); + await log.eraseSubject("user_1", "pseudonymize"); + expect(writeSpy).toHaveBeenCalledOnce(); + const written = writeSpy.mock.calls[0]![0] as string; + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit-erasure"); + expect(parsed.actorId).toBe("user_1"); + expect(parsed.mode).toBe("pseudonymize"); + expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/stdout-json-audit-log.ts`: + +```ts +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Writes one structured JSON line per audit entry to stdout. A log shipper + * (Vector, Fluent Bit) picks these up and forwards to the centralized + * aggregator (Grafana Cloud, Datadog, Loki EU, etc.). + * + * Lines include a `_type` discriminator so the shipper can route: + * "audit" → audit entry + * "audit-erasure" → GDPR erasure tombstone + * + * `eraseSubject` is best-effort: past stdout lines can't be retroactively + * removed. The tombstone informs the downstream aggregator to filter/delete. + */ +export class StdoutJsonAuditLog implements IAuditLog { + async record(entry: AuditEntry): Promise { + const serialized = JSON.stringify({ + _type: "audit", + ...entry, + at: entry.at.toISOString(), + }); + process.stdout.write(serialized + "\n"); + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const tombstone = { + _type: "audit-erasure", + actorId, + mode, + at: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(tombstone) + "\n"); + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/stdout-json-audit-log.ts packages/core-audit/src/stdout-json-audit-log.test.ts +git commit -m "feat(core-audit): StdoutJsonAuditLog impl with audit + audit-erasure markers" +``` + +### Task 2.5: auditLogs Payload collection + +**Files:** +- Create: `packages/core-audit/src/audit-logs-collection.ts` (+ test) + +- [ ] **Step 1: Write the collection-shape test** + +Create `packages/core-audit/src/audit-logs-collection.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { auditLogsCollection } from "./audit-logs-collection"; + +describe("auditLogsCollection", () => { + it("uses slug 'audit-logs'", () => { + expect(auditLogsCollection.slug).toBe("audit-logs"); + }); + + it("is append-only (update: () => false)", () => { + const access = auditLogsCollection.access as Record boolean>; + expect(access.update()).toBe(false); + }); + + it("has the required fields", () => { + const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name); + // WHO + expect(fieldNames).toContain("actorId"); + expect(fieldNames).toContain("actorType"); + expect(fieldNames).toContain("actorRoles"); + // WHAT + expect(fieldNames).toContain("action"); + expect(fieldNames).toContain("resourceType"); + expect(fieldNames).toContain("resourceId"); + expect(fieldNames).toContain("changedFields"); + // SCOPE + expect(fieldNames).toContain("scopeFeature"); + expect(fieldNames).toContain("scopeEnvironment"); + expect(fieldNames).toContain("scopeTenant"); + // WHY + expect(fieldNames).toContain("reason"); + expect(fieldNames).toContain("correlationId"); + expect(fieldNames).toContain("requestId"); + // FROM + expect(fieldNames).toContain("ipTruncated"); + expect(fieldNames).toContain("userAgent"); + // PII + expect(fieldNames).toContain("containsPii"); + expect(fieldNames).toContain("piiCategories"); + // OUTCOME + expect(fieldNames).toContain("outcome"); + expect(fieldNames).toContain("errorCode"); + }); + + it("enables timestamps so createdAt maps to AuditEntry.at", () => { + expect(auditLogsCollection.timestamps).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-audit test audit-logs-collection.test +``` + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/audit-logs-collection.ts`: + +```ts +import type { CollectionConfig } from "payload"; + +/** + * Append-only Payload collection for audit entries. Mounted by core-cms + * when this package is scaffolded (manual wiring step printed by generator). + * + * Access rules: + * - read: admins only + * - create: any authenticated context (filtered upstream by PayloadAuditLog) + * - update: NEVER (compliance requires append-only) + * - delete: admins only (used by the GDPR erasure path with overrideAccess) + * + * The `update: () => false` rule is the compliance backbone. The erasure + * path uses `overrideAccess: true` to bypass for pseudonymization — that's + * Payload's documented escape hatch for privileged operations. + */ +export const auditLogsCollection: CollectionConfig = { + slug: "audit-logs", + access: { + read: ({ req }) => { + const user = req.user as { roles?: string[] } | null | undefined; + return Array.isArray(user?.roles) && user.roles.includes("admin"); + }, + create: () => true, + update: () => false, + delete: ({ req }) => { + const user = req.user as { roles?: string[] } | null | undefined; + return Array.isArray(user?.roles) && user.roles.includes("admin"); + }, + }, + timestamps: true, + fields: [ + // WHO + { name: "actorId", type: "text", required: true, index: true }, + { + name: "actorType", + type: "select", + options: ["user", "system", "service"], + required: true, + }, + { name: "actorRoles", type: "json", required: true }, + + // WHAT + { + name: "action", + type: "select", + options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"], + required: true, + index: true, + }, + { name: "resourceType", type: "text", required: true, index: true }, + { name: "resourceId", type: "text" }, + { name: "changedFields", type: "json" }, + + // SCOPE + { name: "scopeFeature", type: "text", required: true, index: true }, + { name: "scopeEnvironment", type: "text", required: true }, + { name: "scopeTenant", type: "text", required: true, index: true }, + + // WHY + { name: "reason", type: "text" }, + { name: "correlationId", type: "text", index: true }, + { name: "requestId", type: "text" }, + + // FROM + { name: "ipTruncated", type: "text", required: true }, + { name: "userAgent", type: "text", required: true }, + + // PII + { name: "containsPii", type: "checkbox", required: true }, + { name: "piiCategories", type: "json" }, + + // OUTCOME + { + name: "outcome", + type: "select", + options: ["success", "denied", "error"], + required: true, + }, + { name: "errorCode", type: "text" }, + ], +}; +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/audit-logs-collection.ts packages/core-audit/src/audit-logs-collection.test.ts +git commit -m "feat(core-audit): append-only auditLogs Payload collection" +``` + +### Task 2.6: PayloadAuditLog (TDD, record only — eraseSubject in Phase 3) + +**Files:** +- Create: `packages/core-audit/src/payload-audit-log.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/payload-audit-log.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { PayloadAuditLog } from "./payload-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: ["admin"], + action: "UPDATE", + resource: { type: "articles", id: "abc" }, + changedFields: ["title", "body"], + at: new Date("2026-05-11T10:00:00.000Z"), + scope: { feature: "blog", environment: "production", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, + containsPii: false, + outcome: "success", +}; + +describe("PayloadAuditLog.record", () => { + it("maps AuditEntry → flat collection doc + calls payload.create", async () => { + const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" }); + const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.record(sample); + + expect(mockCreate).toHaveBeenCalledOnce(); + const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record }; + expect(call.collection).toBe("audit-logs"); + expect(call.data.actorId).toBe("user_1"); + expect(call.data.action).toBe("UPDATE"); + expect(call.data.resourceType).toBe("articles"); + expect(call.data.resourceId).toBe("abc"); + expect(call.data.changedFields).toEqual(["title", "body"]); + expect(call.data.scopeFeature).toBe("blog"); + expect(call.data.scopeTenant).toBe("default"); + expect(call.data.ipTruncated).toBe("10.0.0.0"); + expect(call.data.containsPii).toBe(false); + expect(call.data.outcome).toBe("success"); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/payload-audit-log.ts`: + +```ts +import type { SanitizedConfig } from "payload"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise<{ + create: (args: { collection: string; data: Record }) => Promise; + find: (args: { + collection: string; + where: Record; + limit: number; + overrideAccess: true; + }) => Promise<{ docs: Array<{ id: string | number }> }>; + update: (args: { + collection: string; + id: string | number; + data: Record; + overrideAccess: true; + }) => Promise; + delete: (args: { + collection: string; + where: Record; + overrideAccess: true; + }) => Promise; +}>; + +/** + * Local-cache audit sink: writes entries to the `audit-logs` Payload + * collection. The collection is append-only by access-rule + * (`update: () => false`); the eraseSubject path uses `overrideAccess: true` + * to bypass for the privileged GDPR pseudonymization op. + * + * The getPayload param is injectable for tests; production callers pass + * the real `getPayload` from `payload`. + */ +export class PayloadAuditLog implements IAuditLog { + constructor( + private readonly config: SanitizedConfig, + private readonly getPayload: GetPayload, + ) {} + + async record(entry: AuditEntry): Promise { + const payload = await this.getPayload({ config: this.config }); + await payload.create({ + collection: "audit-logs", + data: { + actorId: entry.actorId, + actorType: entry.actorType, + actorRoles: entry.actorRoles, + action: entry.action, + resourceType: entry.resource.type, + resourceId: entry.resource.id ?? null, + changedFields: entry.changedFields ?? null, + scopeFeature: entry.scope.feature, + scopeEnvironment: entry.scope.environment, + scopeTenant: entry.scope.tenant, + reason: entry.reason ?? null, + correlationId: entry.correlationId ?? null, + requestId: entry.requestId ?? null, + ipTruncated: entry.from.ipTruncated, + userAgent: entry.from.userAgent, + containsPii: entry.containsPii, + piiCategories: entry.piiCategories ?? null, + outcome: entry.outcome, + errorCode: entry.errorCode ?? null, + }, + }); + } + + async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { + // Implemented in Phase 3. + throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)"); + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts +git commit -m "feat(core-audit): PayloadAuditLog.record impl (eraseSubject lands in Phase 3)" +``` + +### Task 2.7: MultiSinkAuditLog (TDD) + +**Files:** +- Create: `packages/core-audit/src/multi-sink-audit-log.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/multi-sink-audit-log.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MultiSinkAuditLog } from "./multi-sink-audit-log"; +import { NoopAuditLog } from "./noop-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "VIEW", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", +}; + +function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } { + const records: AuditEntry[] = []; + const erasures: string[] = []; + return { + records, + erasures, + async record(e) { records.push(e); }, + async eraseSubject(actorId) { erasures.push(actorId); }, + }; +} + +describe("MultiSinkAuditLog", () => { + it("record() fans out to every sink", async () => { + const a = makeRecorder(); + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + await m.record(sample); + expect(a.records).toHaveLength(1); + expect(b.records).toHaveLength(1); + }); + + it("settle-all: one sink failing does not skip others", async () => { + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} }; + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + + await m.record(sample); + + expect(b.records).toHaveLength(1); // b still received the entry + expect(errSpy).toHaveBeenCalledOnce(); + const written = errSpy.mock.calls[0]![0] as string; + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit-sink-error"); + expect(parsed.error).toContain("a-fail"); + errSpy.mockRestore(); + }); + + it("eraseSubject() fans out to every sink", async () => { + const a = makeRecorder(); + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + await m.eraseSubject("user_1", "delete"); + expect(a.erasures).toEqual(["user_1"]); + expect(b.erasures).toEqual(["user_1"]); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/multi-sink-audit-log.ts`: + +```ts +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Fan-out wrapper. Delivers each entry to every inner sink with settle-all + * semantics — one failing sink doesn't drop the audit entry from others. + * + * Failures emit a structured `audit-sink-error` JSON line to stderr. + * Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the + * sinks failing and we routed the error back through Sentry's reporter, + * we'd loop. Stderr is consumed by the same log shipper as audit entries + * themselves, so the operator sees the failure in their aggregator. + */ +export class MultiSinkAuditLog implements IAuditLog { + constructor(private readonly sinks: IAuditLog[]) {} + + async record(entry: AuditEntry): Promise { + const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry))); + for (const r of results) { + if (r.status === "rejected") { + this.reportSinkError(r.reason); + } + } + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const results = await Promise.allSettled( + this.sinks.map((s) => s.eraseSubject(actorId, mode)), + ); + for (const r of results) { + if (r.status === "rejected") { + this.reportSinkError(r.reason); + } + } + } + + private reportSinkError(reason: unknown): void { + const line = JSON.stringify({ + _type: "audit-sink-error", + error: String(reason), + at: new Date().toISOString(), + }); + process.stderr.write(line + "\n"); + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/multi-sink-audit-log.ts packages/core-audit/src/multi-sink-audit-log.test.ts +git commit -m "feat(core-audit): MultiSinkAuditLog fan-out with settle-all + stderr fallback" +``` + +### Task 2.8: bindAudit binder (TDD) + +**Files:** +- Create: `packages/core-audit/src/di/bind-audit.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/di/bind-audit.test.ts`: + +```ts +import "reflect-metadata"; +import { describe, it, expect, vi } from "vitest"; +import { Container } from "inversify"; +import { bindAudit } from "./bind-audit"; +import { AUDIT_SYMBOLS } from "./symbols"; +import { NoopAuditLog } from "../noop-audit-log"; +import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; +import { PayloadAuditLog } from "../payload-audit-log"; +import { MultiSinkAuditLog } from "../multi-sink-audit-log"; +import type { IAuditLog } from "../audit-log.interface"; + +describe("bindAudit", () => { + it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => { + const container = new Container(); + bindAudit(container, { payloadConfig: {} as never }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(MultiSinkAuditLog); + }); + + it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => { + const container = new Container(); + bindAudit(container, {}); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog); + }); + + it("returns NoopAuditLog when sinks=[]", () => { + const container = new Container(); + bindAudit(container, { sinks: [] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(NoopAuditLog); + }); + + it("returns PayloadAuditLog when sinks=['payload'] only", () => { + const container = new Container(); + bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(PayloadAuditLog); + }); + + it("validates AUDIT_PSEUDONYM_SALT in production", () => { + const oldEnv = process.env.NODE_ENV; + const oldSalt = process.env.AUDIT_PSEUDONYM_SALT; + process.env.NODE_ENV = "production"; + delete process.env.AUDIT_PSEUDONYM_SALT; + expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow( + /AUDIT_PSEUDONYM_SALT/, + ); + process.env.NODE_ENV = oldEnv; + if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt; + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/di/bind-audit.ts`: + +```ts +import "reflect-metadata"; +import type { Container } from "inversify"; +import { getPayload, type SanitizedConfig } from "payload"; +import { NoopAuditLog } from "../noop-audit-log"; +import { PayloadAuditLog } from "../payload-audit-log"; +import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; +import { MultiSinkAuditLog } from "../multi-sink-audit-log"; +import type { IAuditLog } from "../audit-log.interface"; +import { AUDIT_SYMBOLS } from "./symbols"; + +export type BindAuditOpts = { + /** Payload config; required if "payload" is in sinks. */ + payloadConfig?: SanitizedConfig; + /** Sink selection. Default ["payload", "stdout"]. */ + sinks?: ("payload" | "stdout")[]; +}; + +/** + * Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`. + * + * Default sink set: ["payload", "stdout"] — Payload local cache + structured + * JSON to stdout (operator wires a log shipper to the centralized aggregator). + * + * In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast + * if not — better to refuse to start than to ship audit data with a dev-fallback + * salt that an attacker could reverse. + * + * Note: Phase 4 wraps the returned auditLog in TraceIdEnrichingAuditLog + * for OTel correlation. Phase 2 returns the inner sink/fan-out directly. + */ +export function bindAudit( + container: Container, + opts: BindAuditOpts = {}, +): { auditLog: IAuditLog } { + if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) { + throw new Error( + "AUDIT_PSEUDONYM_SALT environment variable is required in production. " + + "Generate via `openssl rand -hex 32` and store in your secrets manager.", + ); + } + + const sinkList = opts.sinks ?? ["payload", "stdout"]; + const sinks: IAuditLog[] = []; + if (sinkList.includes("payload") && opts.payloadConfig) { + sinks.push(new PayloadAuditLog(opts.payloadConfig, getPayload)); + } + if (sinkList.includes("stdout")) { + sinks.push(new StdoutJsonAuditLog()); + } + + const auditLog: IAuditLog = + sinks.length > 1 ? new MultiSinkAuditLog(sinks) + : sinks.length === 1 ? sinks[0]! + : new NoopAuditLog(); + + if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) { + container.unbind(AUDIT_SYMBOLS.IAuditLog); + } + container.bind(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog); + + return { auditLog }; +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/di/bind-audit.ts packages/core-audit/src/di/bind-audit.test.ts +git commit -m "feat(core-audit): bindAudit binder with sink selection + prod salt validation" +``` + +### Task 2.9: RecordingAuditLog in core-testing (TDD) + +**Files:** +- Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test) +- Modify: `packages/core-testing/src/instrumentation/index.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/core-testing/src/instrumentation/recording-audit-log.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { RecordingAuditLog } from "./recording-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "CREATE", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", +}; + +describe("RecordingAuditLog", () => { + it("record() pushes to recorded[]", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + expect(log.recorded).toHaveLength(1); + expect(log.recorded[0]!.actorId).toBe("user_1"); + }); + + it("eraseSubject(pseudonymize) tracks erasure + rewrites actorId in recorded", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.eraseSubject("user_1", "pseudonymize"); + expect(log.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]); + expect(log.recorded[0]!.actorId).toBe("erased-user_1"); // sentinel rewrite + }); + + it("eraseSubject(delete) removes matching entries from recorded", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.record({ ...sample, actorId: "user_2" }); + await log.eraseSubject("user_1", "delete"); + expect(log.recorded.map((r) => r.actorId)).toEqual(["user_2"]); + expect(log.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]); + }); + + it("reset() clears recorded + erasures", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.eraseSubject("user_1", "pseudonymize"); + log.reset(); + expect(log.recorded).toEqual([]); + expect(log.erasures).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-testing test recording-audit-log.test +``` + +- [ ] **Step 3: Implement** + +Create `packages/core-testing/src/instrumentation/recording-audit-log.ts`: + +```ts +import type { AuditEntry } from "@repo/core-shared/audit"; + +/** + * Test-side recording double for IAuditLog. Mirrors Payload semantics in + * eraseSubject (pseudonymize rewrites in place; delete filters out) so tests + * can assert against the same observable state the real impl produces. + * + * Use directly via constructor injection in factory-function tests — no + * container manipulation needed. + */ +export class RecordingAuditLog { + public recorded: AuditEntry[] = []; + public erasures: { actorId: string; mode: "pseudonymize" | "delete" }[] = []; + + async record(entry: AuditEntry): Promise { + this.recorded.push(entry); + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + this.erasures.push({ actorId, mode }); + if (mode === "pseudonymize") { + for (const r of this.recorded) { + if (r.actorId === actorId) { + r.actorId = `erased-${actorId}`; + } + } + } else { + this.recorded = this.recorded.filter((r) => r.actorId !== actorId); + } + } + + reset(): void { + this.recorded = []; + this.erasures = []; + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Update core-testing barrel** + +Append to `packages/core-testing/src/instrumentation/index.ts`: + +```ts +export { RecordingAuditLog } from "./recording-audit-log"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-testing/src/instrumentation/recording-audit-log.ts \ + packages/core-testing/src/instrumentation/recording-audit-log.test.ts \ + packages/core-testing/src/instrumentation/index.ts +git commit -m "feat(core-testing): RecordingAuditLog test double" +``` + +### Task 2.10: Package barrel + Phase 2 gate + +**Files:** +- Create: `packages/core-audit/src/index.ts` + +- [ ] **Step 1: Create barrel** + +Create `packages/core-audit/src/index.ts`: + +```ts +export type { IAuditLog } from "./audit-log.interface"; +export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit"; +export { NoopAuditLog } from "./noop-audit-log"; +export { StdoutJsonAuditLog } from "./stdout-json-audit-log"; +export { PayloadAuditLog } from "./payload-audit-log"; +export { MultiSinkAuditLog } from "./multi-sink-audit-log"; +export { auditLogsCollection } from "./audit-logs-collection"; +export { bindAudit, type BindAuditOpts } from "./di/bind-audit"; +export { AUDIT_SYMBOLS } from "./di/symbols"; +``` + +- [ ] **Step 2: Run all gates** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` +Expected: all green. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-audit/src/index.ts +git commit -m "feat(core-audit): package barrel exports" +``` + +--- + +## Phase 3 — GDPR erasure plumbing + +**Goal:** make erasure actually invokable. `pseudonymize` helper, `PayloadAuditLog.eraseSubject` impl, `createAuditErasureHook` factory, admin tRPC procedure. + +**Files touched:** + +- Create: `packages/core-audit/src/pseudonymize.ts` (+ test) +- Modify: `packages/core-audit/src/payload-audit-log.ts` (eraseSubject impl) +- Update: `packages/core-audit/src/payload-audit-log.test.ts` (add eraseSubject tests) +- Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test) +- Create: `packages/core-audit/src/hooks/index.ts` +- 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` + +### Task 3.1: pseudonymize helper (TDD) + +**Files:** +- Create: `packages/core-audit/src/pseudonymize.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/pseudonymize.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { pseudonymize } from "./pseudonymize"; + +describe("pseudonymize", () => { + let oldSalt: string | undefined; + beforeEach(() => { + oldSalt = process.env.AUDIT_PSEUDONYM_SALT; + process.env.AUDIT_PSEUDONYM_SALT = "test-salt-1"; + }); + afterEach(() => { + if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt; + else delete process.env.AUDIT_PSEUDONYM_SALT; + }); + + it("returns 'erased-{16-hex-chars}'", () => { + const result = pseudonymize("user_1"); + expect(result).toMatch(/^erased-[a-f0-9]{16}$/); + }); + + it("is deterministic — same input → same output", () => { + const a = pseudonymize("user_1"); + const b = pseudonymize("user_1"); + expect(a).toBe(b); + }); + + it("different inputs produce different outputs", () => { + const a = pseudonymize("user_1"); + const b = pseudonymize("user_2"); + expect(a).not.toBe(b); + }); + + it("salt change produces different output", () => { + const a = pseudonymize("user_1"); + process.env.AUDIT_PSEUDONYM_SALT = "test-salt-2"; + const b = pseudonymize("user_1"); + expect(a).not.toBe(b); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/pseudonymize.ts`: + +```ts +import { createHash } from "node:crypto"; + +/** + * Stable pseudonym for an erased actor. SHA-256 of (salt + ":" + actorId), + * truncated to 16 hex chars, prefixed `erased-`. Returns the same pseudonym + * for the same input + salt — compliance auditors can verify that two + * entries with `erased-abc...` came from the same original actor without + * knowing who. + * + * The salt comes from AUDIT_PSEUDONYM_SALT env. In production, the binder + * (bindAudit) validates this env is set; in dev/test, a fallback salt is + * used (NOT acceptable for production data). + */ +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)}`; +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/pseudonymize.ts packages/core-audit/src/pseudonymize.test.ts +git commit -m "feat(core-audit): pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT)" +``` + +### Task 3.2: PayloadAuditLog.eraseSubject (TDD) + +**Files:** +- Modify: `packages/core-audit/src/payload-audit-log.ts` +- Modify: `packages/core-audit/src/payload-audit-log.test.ts` + +- [ ] **Step 1: Add failing tests to existing test file** + +Append to `packages/core-audit/src/payload-audit-log.test.ts`: + +```ts +describe("PayloadAuditLog.eraseSubject", () => { + it("mode='delete' calls payload.delete with overrideAccess + where actorId equals", async () => { + const mockDelete = vi.fn().mockResolvedValue({ docs: [] }); + const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete, create: vi.fn() }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("user_1", "delete"); + + expect(mockDelete).toHaveBeenCalledOnce(); + const call = mockDelete.mock.calls[0]![0] as { + collection: string; + where: { actorId: { equals: string } }; + overrideAccess: boolean; + }; + expect(call.collection).toBe("audit-logs"); + expect(call.where.actorId.equals).toBe("user_1"); + expect(call.overrideAccess).toBe(true); + }); + + it("mode='pseudonymize' fetches matching docs + updates each with pseudonym + overrideAccess", async () => { + process.env.AUDIT_PSEUDONYM_SALT = "fixed-salt-for-test"; + const mockFind = vi.fn().mockResolvedValue({ docs: [{ id: "doc_1" }, { id: "doc_2" }] }); + const mockUpdate = vi.fn().mockResolvedValue({}); + const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate, create: vi.fn() }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("user_1", "pseudonymize"); + + expect(mockFind).toHaveBeenCalledOnce(); + expect(mockUpdate).toHaveBeenCalledTimes(2); + const firstUpdate = mockUpdate.mock.calls[0]![0] as { + data: { actorId: string }; + overrideAccess: boolean; + }; + expect(firstUpdate.data.actorId).toMatch(/^erased-[a-f0-9]{16}$/); + expect(firstUpdate.overrideAccess).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-audit test payload-audit-log.test +``` + +(The existing `record` test still passes; the new tests fail because eraseSubject still throws.) + +- [ ] **Step 3: Replace the eraseSubject stub with the impl** + +In `packages/core-audit/src/payload-audit-log.ts`, replace the `eraseSubject` body: + +```ts +import { pseudonymize } from "./pseudonymize"; + +// ... existing class ... + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const payload = await this.getPayload({ config: this.config }); + if (mode === "delete") { + await payload.delete({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + overrideAccess: true, + }); + return; + } + // pseudonymize + const pseudonym = pseudonymize(actorId); + const matches = await payload.find({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + limit: 10_000, + overrideAccess: true, + }); + for (const doc of matches.docs) { + await payload.update({ + collection: "audit-logs", + id: doc.id, + data: { actorId: pseudonym }, + overrideAccess: true, + }); + } + } +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts +git commit -m "feat(core-audit): PayloadAuditLog.eraseSubject (pseudonymize + delete via overrideAccess)" +``` + +### Task 3.3: createAuditErasureHook factory (TDD) + +**Files:** +- Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test) +- Create: `packages/core-audit/src/hooks/index.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/hooks/audit-erasure-hook.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { createAuditErasureHook } from "./audit-erasure-hook"; +import type { IAuditLog } from "../audit-log.interface"; + +function makeAuditLog(): IAuditLog & { erasures: { actorId: string; mode: string }[] } { + const erasures: { actorId: string; mode: string }[] = []; + return { + erasures, + record: vi.fn(), + async eraseSubject(actorId, mode) { erasures.push({ actorId, mode }); }, + }; +} + +describe("createAuditErasureHook", () => { + it("calls auditLog.eraseSubject(doc.id, 'pseudonymize') by default", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook({ doc: { id: "user_1" } } as never); + expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]); + }); + + it("uses mode='delete' when configured", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog, mode: "delete" }); + await hook({ doc: { id: "user_1" } } as never); + expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]); + }); + + it("coerces numeric doc.id to string", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook({ doc: { id: 42 } } as never); + expect(auditLog.erasures).toEqual([{ actorId: "42", mode: "pseudonymize" }]); + }); + + it("skips non-string/non-numeric ids", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook({ doc: { id: undefined } } as never); + expect(auditLog.erasures).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/hooks/audit-erasure-hook.ts`: + +```ts +import type { CollectionAfterDeleteHook } from "payload"; +import type { IAuditLog } from "../audit-log.interface"; + +export type AuditErasureHookOpts = { + auditLog: IAuditLog; + /** Default 'pseudonymize'. Use 'delete' for collections requiring hard-erase. */ + mode?: "pseudonymize" | "delete"; +}; + +/** + * Payload afterDelete hook that triggers GDPR erasure on the audit log + * when a subject (typically a user) is deleted. Install on user-shaped + * collections via the collection's `hooks.afterDelete` array. + * + * Has no knowledge of collection schemas beyond expecting a `doc.id` + * (string or number). Works for any subject-shaped collection. + */ +export function createAuditErasureHook( + opts: AuditErasureHookOpts, +): CollectionAfterDeleteHook { + const mode = opts.mode ?? "pseudonymize"; + return async ({ doc }) => { + const id = doc.id; + if (typeof id !== "string" && typeof id !== "number") return; + await opts.auditLog.eraseSubject(String(id), mode); + }; +} +``` + +- [ ] **Step 4: Create hooks barrel** + +Create `packages/core-audit/src/hooks/index.ts`: + +```ts +export { + createAuditErasureHook, + type AuditErasureHookOpts, +} from "./audit-erasure-hook"; +``` + +- [ ] **Step 5: Run → PASS** + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-audit/src/hooks/audit-erasure-hook.ts \ + packages/core-audit/src/hooks/audit-erasure-hook.test.ts \ + packages/core-audit/src/hooks/index.ts +git commit -m "feat(core-audit): createAuditErasureHook Payload afterDelete factory" +``` + +### Task 3.4: Admin tRPC procedure (TDD) + +**Files:** +- Create: `packages/core-audit/src/integrations/api/procedures.ts` +- Create: `packages/core-audit/src/integrations/api/router.ts` (+ test) + +- [ ] **Step 1: Create the procedure helper** + +Create `packages/core-audit/src/integrations/api/procedures.ts`: + +```ts +import { initTRPC, TRPCError } from "@trpc/server"; +import type { TrpcContext } from "@repo/core-shared/trpc/context"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +const t = initTRPC.context().create(); + +const adminOnly = t.middleware(({ ctx, next }) => { + const user = ctx.user as { roles?: string[] } | null | undefined; + if (!user?.roles?.includes("admin")) { + throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" }); + } + return next(); +}); + +/** Feature-scoped audit procedure: admin-only, no domain errors yet. */ +export const auditProcedure = t.procedure + .use(defineErrorMiddleware([])) + .use(adminOnly); + +export const router = t.router; +``` + +- [ ] **Step 2: Write failing test for the router** + +Create `packages/core-audit/src/integrations/api/router.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { auditRouter } from "./router"; +import type { IAuditLog } from "../../audit-log.interface"; + +function makeCtx(opts: { admin: boolean; auditLog?: IAuditLog }) { + return { + user: opts.admin ? { id: "u1", roles: ["admin"] } : { id: "u2", roles: [] }, + auditLog: opts.auditLog, + }; +} + +describe("auditRouter.eraseSubject", () => { + it("admin can invoke and the call delegates to ctx.auditLog.eraseSubject", async () => { + const eraseSpy = vi.fn().mockResolvedValue(undefined); + const auditLog: IAuditLog = { + record: vi.fn(), + eraseSubject: eraseSpy, + }; + const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never); + const result = await caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }); + expect(result).toEqual({ ok: true }); + expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); + + it("non-admin gets FORBIDDEN", async () => { + const caller = auditRouter.createCaller(makeCtx({ admin: false }) as never); + await expect( + caller.eraseSubject({ actorId: "user_1", mode: "delete" }), + ).rejects.toThrow(TRPCError); + }); + + it("defaults mode to 'pseudonymize' when omitted", async () => { + const eraseSpy = vi.fn().mockResolvedValue(undefined); + const auditLog: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy }; + const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never); + await caller.eraseSubject({ actorId: "user_1" }); + expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); +}); +``` + +- [ ] **Step 3: Run → FAIL** + +- [ ] **Step 4: Implement the router** + +Create `packages/core-audit/src/integrations/api/router.ts`: + +```ts +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { auditProcedure, router } from "./procedures"; + +export const auditRouter = router({ + eraseSubject: auditProcedure + .input( + z.object({ + actorId: z.string().min(1), + mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"), + }).strict(), + ) + .mutation(async ({ input, ctx }) => { + const auditLog = (ctx as { auditLog?: { eraseSubject: (id: string, mode: "pseudonymize" | "delete") => Promise } }).auditLog; + if (!auditLog) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Audit log not bound on context", + }); + } + await auditLog.eraseSubject(input.actorId, input.mode); + return { ok: true as const }; + }), +}); + +export type AuditRouter = typeof auditRouter; +``` + +- [ ] **Step 5: Run → PASS** + +- [ ] **Step 6: Update package barrel** + +Modify `packages/core-audit/src/index.ts` to add: + +```ts +export { auditRouter, type AuditRouter } from "./integrations/api/router"; +export { createAuditErasureHook, type AuditErasureHookOpts } from "./hooks"; +export { pseudonymize } from "./pseudonymize"; +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/core-audit/src/integrations/api/ \ + packages/core-audit/src/index.ts +git commit -m "feat(core-audit): admin tRPC procedure for eraseSubject" +``` + +### Task 3.5: Phase 3 verification gate + +- [ ] **Step 1: Run all gates** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` +Expected: all green. + +(No commit; verification only.) + +--- + +## Phase 4 — OTel correlation bridge + +**Goal:** `AuditEntry.correlationId` auto-populates from active OTel span. Decorator pattern at bind time. + +**Files touched:** + +- Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test) +- Modify: `packages/core-shared/src/instrumentation/otel/index.ts` +- Modify: `packages/core-shared/src/instrumentation/index.ts` +- Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test) +- Modify: `packages/core-audit/src/di/bind-audit.ts` (wrap with decorator) +- Modify: `packages/core-audit/src/index.ts` + +### Task 4.1: currentTraceId helper (TDD) + +**Files:** +- Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts`: + +```ts +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { trace } from "@opentelemetry/api"; +import { tracing } from "@opentelemetry/sdk-node"; +import { currentTraceId } from "./current-trace-id"; + +const exporter = new tracing.InMemorySpanExporter(); +const provider = new tracing.BasicTracerProvider({ + spanProcessors: [new tracing.SimpleSpanProcessor(exporter)], +}); + +beforeAll(() => { + trace.setGlobalTracerProvider(provider); +}); + +afterEach(() => exporter.reset()); + +describe("currentTraceId", () => { + it("returns undefined when no active span", () => { + expect(currentTraceId()).toBeUndefined(); + }); + + it("returns the active span's traceId when inside startActiveSpan", async () => { + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test-span", (span) => { + const id = currentTraceId(); + expect(id).toBeDefined(); + expect(id).toMatch(/^[a-f0-9]{32}$/); + span.end(); + resolve(); + }); + }); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +pnpm --filter @repo/core-shared test current-trace-id.test +``` + +- [ ] **Step 3: Implement** + +Create `packages/core-shared/src/instrumentation/otel/current-trace-id.ts`: + +```ts +import { trace } from "@opentelemetry/api"; + +/** + * Returns the trace ID of the currently active OTel span, or undefined if + * there is no active span (e.g., outside any request context, in unit tests + * without an OTel SDK). + * + * Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate + * AuditEntry.correlationId so callers don't have to thread it explicitly. + * + * Returns undefined for the all-zeros invalid trace ID — OTel emits this + * when context propagation hasn't kicked in. + */ +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; +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Update barrels** + +Append to `packages/core-shared/src/instrumentation/otel/index.ts`: + +```ts +export { currentTraceId } from "./current-trace-id"; +``` + +Append to `packages/core-shared/src/instrumentation/index.ts`: + +```ts +export { currentTraceId } from "./otel/current-trace-id"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-shared/src/instrumentation/otel/current-trace-id.ts \ + packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts \ + packages/core-shared/src/instrumentation/otel/index.ts \ + packages/core-shared/src/instrumentation/index.ts +git commit -m "feat(core-shared): currentTraceId helper for OTel-audit correlation bridge" +``` + +### Task 4.2: TraceIdEnrichingAuditLog decorator (TDD) + +**Files:** +- Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/trace-id-enriching-audit-log.test.ts`: + +```ts +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { trace } from "@opentelemetry/api"; +import { tracing } from "@opentelemetry/sdk-node"; +import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +const exporter = new tracing.InMemorySpanExporter(); +const provider = new tracing.BasicTracerProvider({ + spanProcessors: [new tracing.SimpleSpanProcessor(exporter)], +}); + +beforeAll(() => trace.setGlobalTracerProvider(provider)); +afterEach(() => exporter.reset()); + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "VIEW", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", +}; + +function makeInner(): IAuditLog & { records: AuditEntry[] } { + const records: AuditEntry[] = []; + return { + records, + async record(e) { records.push(e); }, + eraseSubject: vi.fn(), + }; +} + +describe("TraceIdEnrichingAuditLog", () => { + it("passes through when no active span", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + await wrapper.record(sample); + expect(inner.records[0]!.correlationId).toBeUndefined(); + }); + + it("auto-populates correlationId from active span", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test", async (span) => { + await wrapper.record(sample); + const expected = span.spanContext().traceId; + expect(inner.records[0]!.correlationId).toBe(expected); + span.end(); + resolve(); + }); + }); + }); + + it("explicit correlationId wins over auto-populated", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test", async (span) => { + await wrapper.record({ ...sample, correlationId: "explicit-trace-id" }); + expect(inner.records[0]!.correlationId).toBe("explicit-trace-id"); + span.end(); + resolve(); + }); + }); + }); + + it("eraseSubject passes through unchanged", async () => { + const eraseSpy = vi.fn(); + const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy }; + const wrapper = new TraceIdEnrichingAuditLog(inner); + await wrapper.eraseSubject("user_1", "delete"); + expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete"); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/trace-id-enriching-audit-log.ts`: + +```ts +import type { AuditEntry } from "@repo/core-shared/audit"; +import { currentTraceId } from "@repo/core-shared/instrumentation"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Decorates any IAuditLog by auto-populating AuditEntry.correlationId from + * the active OTel span (when present and the caller didn't supply a value). + * Caller-supplied correlationId always wins — explicit > implicit. + * + * Applied at bind time by bindAudit so all sinks see entries with + * correlationId already set. Single source of truth for the OTel-audit bridge. + */ +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); + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-audit/src/trace-id-enriching-audit-log.ts \ + packages/core-audit/src/trace-id-enriching-audit-log.test.ts +git commit -m "feat(core-audit): TraceIdEnrichingAuditLog decorator for OTel correlation" +``` + +### Task 4.3: Wire decorator into bindAudit + update barrel + +**Files:** +- Modify: `packages/core-audit/src/di/bind-audit.ts` +- Modify: `packages/core-audit/src/di/bind-audit.test.ts` +- Modify: `packages/core-audit/src/index.ts` + +- [ ] **Step 1: Update bindAudit to wrap with the decorator** + +In `packages/core-audit/src/di/bind-audit.ts`, find the line: + +```ts + const auditLog: IAuditLog = + sinks.length > 1 ? new MultiSinkAuditLog(sinks) + : sinks.length === 1 ? sinks[0]! + : new NoopAuditLog(); +``` + +Replace with: + +```ts +import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log"; + + const inner: IAuditLog = + sinks.length > 1 ? new MultiSinkAuditLog(sinks) + : sinks.length === 1 ? sinks[0]! + : new NoopAuditLog(); + const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner); +``` + +- [ ] **Step 2: Update bind-audit test** + +Existing tests assert `instanceof MultiSinkAuditLog` / `instanceof StdoutJsonAuditLog`. Now those instances are wrapped in `TraceIdEnrichingAuditLog`. Update each `expect(auditLog).toBeInstanceOf(X)` to: + +```ts +expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); +// Use a typed accessor to assert inner type: +expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(X); +``` + +(Add `import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";` to the test file.) + +- [ ] **Step 3: Run → PASS** + +```bash +pnpm --filter @repo/core-audit test bind-audit.test +``` + +- [ ] **Step 4: Update package barrel** + +Modify `packages/core-audit/src/index.ts` to add: + +```ts +export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; +``` + +- [ ] **Step 5: Phase 4 verification gate** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-audit/src/di/bind-audit.ts \ + packages/core-audit/src/di/bind-audit.test.ts \ + packages/core-audit/src/index.ts +git commit -m "feat(core-audit): wrap bound auditLog with TraceIdEnrichingAuditLog" +``` + +--- + +## Phase 5 — VIEW capture: createAuditAfterReadHook + +**Goal:** ship the `afterRead` hook factory for opt-in automatic VIEW capture. + +**Files touched:** + +- Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test) +- Modify: `packages/core-audit/src/hooks/index.ts` +- Modify: `packages/core-audit/src/index.ts` + +### Task 5.1: createAuditAfterReadHook (TDD) + +**Files:** +- Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test) + +- [ ] **Step 1: Write failing test** + +Create `packages/core-audit/src/hooks/audit-after-read-hook.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { createAuditAfterReadHook } from "./audit-after-read-hook"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "../audit-log.interface"; + +function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } { + const recorded: AuditEntry[] = []; + return { + recorded, + async record(e) { recorded.push(e); }, + eraseSubject: vi.fn(), + }; +} + +function baseOpts(auditLog: IAuditLog) { + return { + auditLog, + resourceType: "users", + feature: "auth", + environment: "test", + resolveTenant: () => "default", + containsPii: true, + piiCategories: ["email"], + }; +} + +describe("createAuditAfterReadHook", () => { + it("emits a VIEW entry with the resource type + feature + tenant", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditAfterReadHook(baseOpts(auditLog)); + + const doc = { id: "abc", email: "x@y.com" }; + const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" }; + await hook({ doc, req } as never); + + // Wait one tick for fire-and-forget to flush + await new Promise((r) => setImmediate(r)); + + expect(auditLog.recorded).toHaveLength(1); + const e = auditLog.recorded[0]!; + expect(e.action).toBe("VIEW"); + expect(e.resource.type).toBe("users"); + expect(e.resource.id).toBe("abc"); + expect(e.actorId).toBe("user_1"); + expect(e.actorRoles).toEqual(["user"]); + expect(e.scope.feature).toBe("auth"); + expect(e.scope.tenant).toBe("default"); + expect(e.containsPii).toBe(true); + expect(e.piiCategories).toEqual(["email"]); + expect(e.outcome).toBe("success"); + expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied + }); + + it("uses 'system' actor when req.user is null", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditAfterReadHook(baseOpts(auditLog)); + await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); + await new Promise((r) => setImmediate(r)); + expect(auditLog.recorded[0]!.actorId).toBe("system"); + expect(auditLog.recorded[0]!.actorType).toBe("system"); + }); + + it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditAfterReadHook(baseOpts(auditLog)); + await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); + await new Promise((r) => setImmediate(r)); + expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal"); + expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal"); + }); + + it("shouldSkip predicate prevents emission", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true }); + await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); + await new Promise((r) => setImmediate(r)); + expect(auditLog.recorded).toHaveLength(0); + }); + + it("returns the doc unchanged (afterRead hook contract)", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditAfterReadHook(baseOpts(auditLog)); + const doc = { id: "abc", title: "Hello" }; + const result = await hook({ doc, req: { user: null, headers: {} } } as never); + expect(result).toBe(doc); + }); + + it("audit-sink failures do not propagate (fire-and-forget)", async () => { + const auditLog: IAuditLog = { + record: async () => { throw new Error("sink-failed"); }, + eraseSubject: vi.fn(), + }; + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const hook = createAuditAfterReadHook(baseOpts(auditLog)); + await expect( + hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never), + ).resolves.toBeDefined(); + // Give the microtask queue a moment to flush the catch handler + await new Promise((r) => setImmediate(r)); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** + +Create `packages/core-audit/src/hooks/audit-after-read-hook.ts`: + +```ts +import type { CollectionAfterReadHook } from "payload"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import { truncateIp } from "@repo/core-shared/audit"; +import type { IAuditLog } from "../audit-log.interface"; + +export type AuditAfterReadHookOpts = { + auditLog: IAuditLog; + /** Resource type for AuditEntry.resource.type (e.g., "users"). */ + resourceType: string; + /** Feature attribution for AuditEntry.scope.feature. */ + feature: string; + /** Deployment environment. */ + environment: string; + /** Tenant resolver — single-tenant projects return "default". */ + resolveTenant: (req: { user?: { id: string; tenantId?: string } | null }) => string; + /** Whether this collection contains PII. Propagates to every entry. */ + containsPii: boolean; + /** Optional PII categories applicable to all entries from this collection. */ + piiCategories?: string[]; + /** Optional predicate; return true to skip emitting an entry. */ + shouldSkip?: (args: { req: unknown; doc: { id: string | number } }) => boolean; +}; + +/** + * Payload afterRead hook factory. Emits a VIEW AuditEntry per document read. + * Per-collection opt-in: install via `hooks.afterRead: [createAuditAfterReadHook(...)]` + * on the collection config. + * + * Fire-and-forget: a failing audit sink does NOT propagate up to break the + * user-facing read. Failures emit a structured error to stderr (visible to + * the same log shipper as audit entries themselves). + * + * Combine with use-case-level record() calls for app-facing reads; this hook + * covers direct CMS/admin/programmatic reads. The use-case path captures + * "why" (reason); this hook captures "the system saw this doc". + */ +export function createAuditAfterReadHook( + opts: AuditAfterReadHookOpts, +): CollectionAfterReadHook { + return ({ doc, req }) => { + if (opts.shouldSkip?.({ req, doc: doc as { id: string | number } })) { + return doc; + } + + const actor = (req as { user?: { id: string; roles?: string[]; tenantId?: string } | null }).user; + const entry: AuditEntry = { + actorId: actor?.id ?? "system", + actorType: actor ? "user" : "system", + actorRoles: actor?.roles ?? [], + action: "VIEW", + resource: { + type: opts.resourceType, + id: typeof doc.id === "string" || typeof doc.id === "number" ? String(doc.id) : undefined, + }, + at: new Date(), + scope: { + feature: opts.feature, + environment: opts.environment, + tenant: opts.resolveTenant(req as { user?: { id: string; tenantId?: string } | null }), + }, + reason: "payload-afterRead-hook", + from: { + ipTruncated: extractIpTruncated(req) ?? "internal", + userAgent: extractUserAgent(req) ?? "payload-internal", + }, + containsPii: opts.containsPii, + piiCategories: opts.piiCategories, + outcome: "success", + }; + + // Fire-and-forget — never break the read. + void opts.auditLog.record(entry).catch((err: unknown) => { + process.stderr.write( + JSON.stringify({ + _type: "audit-hook-error", + hook: "afterRead", + resourceType: opts.resourceType, + error: String(err), + at: new Date().toISOString(), + }) + "\n", + ); + }); + + return doc; + }; +} + +function extractIpTruncated(req: unknown): string | undefined { + const r = req as { ip?: string; headers?: Record }; + const rawIp = r.ip ?? r.headers?.["x-forwarded-for"]; + if (!rawIp) return undefined; + const candidate = Array.isArray(rawIp) ? rawIp[0]! : rawIp.split(",")[0]!.trim(); + try { + return truncateIp(candidate); + } catch { + return undefined; + } +} + +function extractUserAgent(req: unknown): string | undefined { + const r = req as { headers?: Record }; + const ua = r.headers?.["user-agent"]; + if (!ua) return undefined; + return Array.isArray(ua) ? ua[0] : ua; +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Update hooks barrel + package barrel** + +Append to `packages/core-audit/src/hooks/index.ts`: + +```ts +export { + createAuditAfterReadHook, + type AuditAfterReadHookOpts, +} from "./audit-after-read-hook"; +``` + +Modify `packages/core-audit/src/index.ts` to add: + +```ts +export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks"; +``` + +- [ ] **Step 6: Phase 5 verification gate** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/core-audit/src/hooks/audit-after-read-hook.ts \ + packages/core-audit/src/hooks/audit-after-read-hook.test.ts \ + packages/core-audit/src/hooks/index.ts \ + packages/core-audit/src/index.ts +git commit -m "feat(core-audit): createAuditAfterReadHook factory for opt-in VIEW capture" +``` + +--- + +## Phase 6 — ADR-018 + generator template + docs + +**Goal:** publish ADR-018, write `docs/guides/audit-and-compliance.md`, capture core-audit as a generator template (`pnpm turbo gen core-package audit`), update CLAUDE.md / AGENTS.md / template-tiers / README / data-flow-explainer / scaffolding-doc. + +**Files touched:** + +- Create: `docs/decisions/adr-018-audit-and-compliance.md` +- Create: `docs/guides/audit-and-compliance.md` +- Create: `turbo/generators/templates/core-package/audit/**` (all package files as `.hbs`) +- Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json` +- Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts` +- Modify: `turbo/generators/config.ts` (push `audit` entry; add to choices) +- Modify: `docs/architecture/template-tiers.md` +- Modify: `docs/scaffolding/core-package-generator.md` +- Modify: `CLAUDE.md` +- Modify: `AGENTS.md` +- Modify: `docs/architecture/data-flow-explainer.html` +- Modify: `README.md` + +### Task 6.1: Write ADR-018 + +**Files:** +- Create: `docs/decisions/adr-018-audit-and-compliance.md` + +- [ ] **Step 1: Inspect an existing ADR for format** + +```bash +head -100 docs/decisions/adr-017-opentelemetry-migration.md +``` + +- [ ] **Step 2: Write the ADR** + +Create `docs/decisions/adr-018-audit-and-compliance.md`: + +```markdown +# 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 collection** — `update: () => 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 bridge** — `currentTraceId()` 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 required** — `AuditEntry.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. +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/decisions/adr-018-audit-and-compliance.md +git commit -m "docs(adr): ADR-018 audit logging & DPA compliance" +``` + +### Task 6.2: Write the audit-and-compliance guide + +**Files:** +- Create: `docs/guides/audit-and-compliance.md` + +- [ ] **Step 1: Write the guide** + +Create `docs/guides/audit-and-compliance.md` with sections: What DPA requires; The two-pattern model; When to use which; Wiring core-audit into your app (7 steps); Sample Vector / Fluent Bit configs; GDPR erasure; Sample-week audit verification; Hostile-actor immutability test; Common mistakes. + +Content (mirror the structure laid out in spec §9.3): + +```markdown +# Audit logging & DPA compliance + +> **Prerequisite:** This guide assumes `@repo/core-audit` is scaffolded. If your project started from the slim template, run `pnpm turbo gen core-package audit` first. + +## What DPA requires + +[Summarize the user-provided compliance doc: 6 action types, 4 required fields, immutability rules, "what NOT to log" list, retention 90d hot / 1y archive, deletable on GDPR request.] + +## The two-pattern model + +Two complementary ways to log a VIEW: + +1. **Use-case-level `record()` calls** — in your feature's READ use cases, the developer explicitly calls `ctx.auditLog?.record({ action: "VIEW", ... })`. Captures the WHY (reason: "user-profile-render") and works in any context (HTTP, jobs, CLI). + +2. **Payload `afterRead` hook (automatic, opt-in)** — install `createAuditAfterReadHook(...)` on a collection. Captures EVERY read of the collection automatically, including admin UI / direct programmatic reads. + +Use both for collections under DPA scope. The hook covers reads you might forget at the use-case layer; the use-case calls add the contextual reason. + +## When to use which + +| Read source | Pattern | +|---|---| +| tRPC procedure (app-facing read) | Use-case-level `record()` call | +| Payload admin UI | Hook automatically captures | +| Background job | Use-case-level `record()` call with `actorId: "system"` | +| Direct programmatic / CMS REST | Hook automatically captures | + +## Wiring core-audit into your app (7 steps) + +[Reproduces the 7-step printed next-steps content from `printAuditNextSteps`.] + +## Sample log-shipper configs + +### Vector + +[Sample Vector config that reads stdout, filters by `_type: "audit"`, ships to Grafana Loki EU.] + +### Fluent Bit + +[Sample Fluent Bit config equivalent.] + +## GDPR erasure + +Trigger via admin tRPC: +[curl example calling `audit.eraseSubject` with admin auth.] + +Or rely on the user-delete hook: when a user is deleted via Payload admin, `createAuditErasureHook` automatically pseudonymizes their audit history. + +## Sample-week audit verification + +"Can you tell who accessed any given record?" + +[Query Payload admin → audit-logs → filter by resourceType + resourceId.] + +## Hostile-actor immutability test + +[How to verify the append-only contract: try to update a row via direct DB access; verify Payload's overrideAccess isn't accidentally enabled elsewhere; confirm the stdout shipper has an independent retention.] + +## Common mistakes + +- Forgetting to set `scope.tenant` (required field). +- `containsPii: false` on a collection that actually has PII. +- Using `oldValue`/`newValue` (those fields don't exist by design — DPA enforcement). +- Forgetting `AUDIT_PSEUDONYM_SALT` in production (bindAudit fails at boot). +``` + +(Each placeholder section above should be expanded to a few paragraphs with real content. The doc is the canonical user-facing reference.) + +- [ ] **Step 2: Commit** + +```bash +git add docs/guides/audit-and-compliance.md +git commit -m "docs(guide): audit-and-compliance how-to guide" +``` + +### Task 6.3: Capture core-audit as a generator template + +**Files:** +- Create: `turbo/generators/templates/core-package/audit/**` (all current `packages/core-audit/` files as `.hbs`) +- Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json` + +- [ ] **Step 1: Mirror the package tree as `.hbs` siblings** + +```bash +mkdir -p turbo/generators/templates/core-package/audit/src/{di,integrations/api,hooks} + +# Top-level files +for f in AGENTS.md eslint.config.js package.json tsconfig.json turbo.json vitest.config.ts; do + cp packages/core-audit/$f turbo/generators/templates/core-package/audit/$f.hbs +done + +# src files +for f in packages/core-audit/src/*.ts; do + base=$(basename "$f") + cp "$f" "turbo/generators/templates/core-package/audit/src/$base.hbs" +done + +# src/di +for f in packages/core-audit/src/di/*.ts; do + base=$(basename "$f") + cp "$f" "turbo/generators/templates/core-package/audit/src/di/$base.hbs" +done + +# src/hooks +for f in packages/core-audit/src/hooks/*.ts; do + base=$(basename "$f") + cp "$f" "turbo/generators/templates/core-package/audit/src/hooks/$base.hbs" +done + +# src/integrations/api +for f in packages/core-audit/src/integrations/api/*.ts; do + base=$(basename "$f") + cp "$f" "turbo/generators/templates/core-package/audit/src/integrations/api/$base.hbs" +done +``` + +- [ ] **Step 2: Generate the byte-identical snapshot** + +```bash +pnpm exec tsx <<'TS' +import { computeSnapshot } from "./turbo/generators/lib/snapshot.js"; +import { writeFileSync } from "node:fs"; +const snap = computeSnapshot("./packages/core-audit"); +writeFileSync( + "./turbo/generators/__snapshots__/core-package/audit.snapshot.json", + JSON.stringify(snap, null, 2) + "\n", +); +console.log(`Wrote ${snap.length} entries`); +TS +``` + +- [ ] **Step 3: Commit** + +```bash +git add turbo/generators/templates/core-package/audit \ + turbo/generators/__snapshots__/core-package/audit.snapshot.json +git commit -m "feat(generators): capture core-audit as verbatim template files" +``` + +### Task 6.4: Wire audit entry into the core-package generator + e2e test + +**Files:** +- Modify: `turbo/generators/config.ts` +- Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts` + +- [ ] **Step 1: Find an existing per-package e2e test to mirror** + +```bash +cat turbo/generators/__tests__/core-package-events.e2e.test.ts +``` + +- [ ] **Step 2: Add audit entry to CORE_PACKAGE_GENERATORS + choices** + +In `turbo/generators/config.ts`, find `CORE_PACKAGE_GENERATORS` and the `choices` list. Add: + +```ts +// Add to choices array: +choices: ["realtime", "events", "trpc", "ui", "audit"], + +// Add to CORE_PACKAGE_GENERATORS dispatch table: +audit: () => [ + () => assertOptionalPackageNotPresent("core-audit"), + ...emitTemplateTree("core-package/audit", "packages/core-audit"), + () => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"), + () => printAuditNextSteps(), +], +``` + +Also add the `printAuditNextSteps()` function alongside the other print functions: + +```ts +function printAuditNextSteps(): string { + return [ + "─────────────────────────────────────────────────────────────", + "@repo/core-audit scaffolded into packages/core-audit/.", + "", + "Manual wiring required (compliance-critical):", + "", + "1. Set AUDIT_PSEUDONYM_SALT env var (production REQUIRED):", + " export AUDIT_PSEUDONYM_SALT=\"$(openssl rand -hex 32)\"", + " Add to your deployment secrets manager.", + "", + "2. Mount the audit-logs Payload collection in packages/core-cms/src/payload.config.ts:", + " import { auditLogsCollection } from \"@repo/core-audit/collection\";", + " // collections: [..., auditLogsCollection],", + "", + "3. Mount the admin tRPC router in packages/core-api/src/root.ts:", + " import { auditRouter } from \"@repo/core-audit/api\";", + " // routers: { ..., audit: auditRouter },", + "", + "4. Bind audit in apps/web-next/src/server/bind-production.ts:", + " const { bindAudit } = await import(\"@repo/core-audit/di\");", + " const { auditLog } = bindAudit(sharedContainer, {", + " payloadConfig: resolvedConfig,", + " sinks: [\"payload\", \"stdout\"],", + " });", + "", + "5. Install user-collection hooks (recommended for DPA compliance):", + " In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:", + " if (ctx.auditLog) {", + " const { createAuditErasureHook, createAuditAfterReadHook } =", + " await import(\"@repo/core-audit/hooks\");", + " // wire onto users collection — see docs/guides/audit-and-compliance.md", + " }", + "", + "6. Set up a log shipper (Vector / Fluent Bit) to forward stdout JSON to", + " your aggregator. See docs/guides/audit-and-compliance.md for configs.", + "", + "7. Verify:", + " pnpm install", + " pnpm lint && pnpm typecheck && pnpm test", + " pnpm turbo boundaries", + "", + "See docs/guides/audit-and-compliance.md for the full guide.", + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} +``` + +- [ ] **Step 3: Create e2e test** + +Create `turbo/generators/__tests__/core-package-audit.e2e.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeSnapshot } from "../lib/snapshot.js"; +import expectedSnapshot from "../__snapshots__/core-package/audit.snapshot.json" with { type: "json" }; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", ".."); + +function stripDep(pkgJsonPath: string, depName: string): void { + const raw = readFileSync(pkgJsonPath, "utf8"); + const parsed = JSON.parse(raw) as Record>; + for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) { + if (parsed[section]?.[depName]) { + delete parsed[section][depName]; + } + } + writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n"); +} + +describe("e2e: core-package audit", () => { + it( + "byte-identical reconstruction matches snapshot", + { timeout: 120_000 }, + () => { + const tmp = mkdtempSync(join(tmpdir(), "e2e-audit-")); + cpSync(REPO_ROOT, tmp, { + recursive: true, + filter: (src) => + !src.includes("node_modules") && + !src.includes(".turbo") && + !src.includes(".git") && + !src.includes("packages/core-audit"), + }); + // Strip @repo/core-audit refs from package.jsons so install succeeds without the package + stripDep(join(tmp, "apps/web-next/package.json"), "@repo/core-audit"); + execSync(`cd ${tmp} && pnpm install --silent`, { stdio: "inherit" }); + execSync(`cd ${tmp} && pnpm turbo gen core-package --args audit`, { stdio: "inherit" }); + const result = computeSnapshot(join(tmp, "packages/core-audit")); + expect(result).toEqual(expectedSnapshot); + }, + ); +}); +``` + +- [ ] **Step 4: Run the e2e test** + +```bash +pnpm --filter @repo/turbo-generators test core-package-audit.e2e +``` +Expected: PASS (~30-60s). + +- [ ] **Step 5: Commit** + +```bash +git add turbo/generators/config.ts turbo/generators/__tests__/core-package-audit.e2e.test.ts +git commit -m "feat(generators): wire audit entry + e2e byte-identical reconstruction test" +``` + +### Task 6.5: Update doc surfaces + +**Files:** +- Modify: `docs/architecture/template-tiers.md` +- Modify: `docs/scaffolding/core-package-generator.md` +- Modify: `CLAUDE.md` +- Modify: `AGENTS.md` +- Modify: `docs/architecture/data-flow-explainer.html` +- Modify: `README.md` + +- [ ] **Step 1: template-tiers.md** + +Open `docs/architecture/template-tiers.md`. Find the optional packages table. Add a row for core-audit: + +```markdown +| core-audit | `pnpm turbo gen core-package audit` | ADR-018 | docs/guides/audit-and-compliance.md | +``` + +- [ ] **Step 2: scaffolding-doc** + +Open `docs/scaffolding/core-package-generator.md`. Find the templates table and add audit: + +```markdown +| `audit` | DPA-compliant audit logging (ADR-018) | Phase 7 | +``` + +- [ ] **Step 3: CLAUDE.md** + +Find the Project Overview block in `CLAUDE.md` and update the optional packages list to include `core-audit`. Find the "Read first" section and add the audit guide: + +```markdown +- `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (*requires `gen core-package audit`*) +``` + +- [ ] **Step 4: AGENTS.md** + +Find the section that lists optional packages. Add `@repo/core-audit` (optional). Add audit row to the generator list if there is one. + +- [ ] **Step 5: data-flow-explainer.html** + +Open `docs/architecture/data-flow-explainer.html`. Find where realtime/events/trpc/ui are marked as conditional (dashed lines / optional tag). Add a similar audit-layer marker. If the explainer doesn't model audit yet, add a brief layer entry between "tRPC" and "Storage". + +- [ ] **Step 6: README.md** + +Find the Optional packages section. Add: + +```bash +pnpm turbo gen core-package audit # DPA-compliant audit logging (ADR-018) +``` + +- [ ] **Step 7: Commit** + +```bash +git add docs/architecture/template-tiers.md \ + docs/scaffolding/core-package-generator.md \ + CLAUDE.md AGENTS.md \ + docs/architecture/data-flow-explainer.html \ + README.md +git commit -m "docs: surface core-audit as 5th optional package across discovery points" +``` + +### Task 6.6: Final verification gate + +- [ ] **Step 1: Run all gates from repo root** + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +``` +Expected: all green. + +- [ ] **Step 2: Confirm e2e test passes** + +```bash +pnpm --filter @repo/turbo-generators test +``` +Expected: 5 byte-identical reconstruction tests pass (realtime, events, trpc, ui, audit). + +- [ ] **Step 3: No commit — verification only** + +Plan complete. + +--- + +## Notes for the executing agent + +- Phases 1 → 6 are sequenced. Don't start a phase until the previous one's gates are green. +- The most subtle piece is Task 4.3's wrapper test update — `instanceof MultiSinkAuditLog` no longer matches because the returned `auditLog` is a `TraceIdEnrichingAuditLog`. The fix is to assert on `.inner` (or use a typed accessor); the test code in Task 4.3 step 2 spells this out. +- Phase 6's e2e test (Task 6.4) requires the byte-identical snapshot generated in Task 6.3 to match exactly. If the snapshot was generated AFTER any changes to `packages/core-audit/` (Phases 1-5 should be done first), this is fine. If you regenerate Phase 1-5 between snapshot generation and the e2e run, snapshots won't match. Generate snapshot LAST. +- The `printAuditNextSteps()` function in Task 6.4 has 7 manual wiring steps. They're long but each is concrete (copy-paste-ready code blocks). Mirror the existing `printRealtimeNextSteps()` / `printEventsNextSteps()` patterns for formatting. +- Auth feature is NOT modified by this plan. Audit's user-collection hook installation is documented in the generator's next-steps; downstream consumers wire it manually after scaffolding. This keeps core-audit truly optional. +- Commit cadence: ~25-30 commits across the six phases. Each commit should leave the repo in a green-gate state.