Files
agentic-dev/docs/superpowers/plans/2026-05-11-audit-and-compliance.md
Danijel Martinek ac8dfcc5d4 docs(plan): audit logging & DPA compliance (6 phases, ~30 commits)
Implementation plan for the spec at docs/superpowers/specs/2026-05-11-
audit-and-compliance-design.md. Six phases:

- Phase 1: AuditLogProtocol + AuditEntry type + truncateIp helper in
  core-shared; BindContext.auditLog? (5th generic).
- Phase 2: @repo/core-audit optional package — NoopAuditLog,
  StdoutJsonAuditLog, append-only audit-logs Payload collection,
  PayloadAuditLog (record), MultiSinkAuditLog fan-out, bindAudit
  binder with prod salt validation, RecordingAuditLog in core-testing.
- Phase 3: GDPR erasure plumbing — pseudonymize helper (sha256 +
  AUDIT_PSEUDONYM_SALT), PayloadAuditLog.eraseSubject via
  overrideAccess, createAuditErasureHook Payload afterDelete factory,
  admin tRPC procedure (audit.eraseSubject).
- Phase 4: OTel correlation bridge — currentTraceId() in core-shared,
  TraceIdEnrichingAuditLog decorator wraps inner sinks at bindAudit
  time. Explicit caller correlationId wins.
- Phase 5: createAuditAfterReadHook factory for opt-in per-collection
  VIEW capture; fire-and-forget semantics with stderr fallback.
- Phase 6: ADR-018 + audit-and-compliance.md guide + generator template
  at turbo/generators/templates/core-package/audit/ + byte-identical
  snapshot + e2e test + 6 doc surface refreshes (template-tiers,
  scaffolding-doc, CLAUDE.md, AGENTS.md, data-flow-explainer, README).

Total: ~30 commits expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:01:11 +02:00

107 KiB

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:

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<AuditAction>().toEqualTypeOf<
      "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"
    >();
  });
});

describe("AuditFrom", () => {
  it("requires ipTruncated and userAgent", () => {
    expectTypeOf<AuditFrom>().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<AuditEntry>();
  });

  it("makes optional fields actually optional", () => {
    type Entry = AuditEntry;
    type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode";
    expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<Partial<Pick<Entry, OptionalKeys>>>();
  });
});
  • Step 2: Run test → FAIL
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:

/**
 * 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
pnpm --filter @repo/core-shared test audit-entry.test

Expected: PASS, 3 tests.

  • Step 5: Commit
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:

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
pnpm --filter @repo/core-shared test truncate-ip.test
  • Step 3: Implement

Create packages/core-shared/src/audit/truncate-ip.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
pnpm --filter @repo/core-shared test truncate-ip.test
  • Step 5: Commit
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:

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

(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:

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:

export * from "./audit";
  • Step 4: Add subpath export

In packages/core-shared/package.json, add to exports (alphabetically among existing entries):

"./audit": "./src/audit/index.ts",
  • Step 5: Verify
pnpm --filter @repo/core-shared typecheck
pnpm --filter @repo/core-shared test

Expected: PASS.

  • Step 6: Commit
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:

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<Bus, Realtime, RealtimeReg, Metrics, Audit> & {
  config: SanitizedConfig;
};
  • Step 2: Verify backward compat
pnpm typecheck

Expected: clean. Existing 4-generic callers still resolve because the 5th generic has a default.

  • Step 3: Commit
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
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
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
mkdir -p packages/core-audit/src/{di,integrations/api,hooks}
  • Step 3: package.json

Create packages/core-audit/package.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:

{
  "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:

{
  "extends": ["//"],
  "tags": ["core"]
}
  • Step 6: vitest.config.ts

Create packages/core-audit/vitest.config.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:

import base from "@repo/core-eslint/base";

export default [...base];
  • Step 8: AGENTS.md

Create packages/core-audit/AGENTS.md:

# @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
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
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:

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<void>  — inherited from protocol
  eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
}

// 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:

export const AUDIT_SYMBOLS = {
  IAuditLog: Symbol.for("core-audit:IAuditLog"),
} as const;
  • Step 3: Verify
pnpm --filter @repo/core-audit typecheck
  • Step 4: Commit
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:

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
pnpm --filter @repo/core-audit test noop-audit-log.test
  • Step 3: Implement

Create packages/core-audit/src/noop-audit-log.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<void> {
    // intentional no-op
  }
  async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
    // intentional no-op
  }
}
  • Step 4: Run → PASS

  • Step 5: Commit

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:

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<typeof vi.spyOn>;
  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:

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<void> {
    const serialized = JSON.stringify({
      _type: "audit",
      ...entry,
      at: entry.at.toISOString(),
    });
    process.stdout.write(serialized + "\n");
  }

  async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
    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

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:

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<string, () => 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
pnpm --filter @repo/core-audit test audit-logs-collection.test
  • Step 3: Implement

Create packages/core-audit/src/audit-logs-collection.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

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:

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<string, unknown> };
    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:

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<string, unknown> }) => Promise<unknown>;
  find: (args: {
    collection: string;
    where: Record<string, unknown>;
    limit: number;
    overrideAccess: true;
  }) => Promise<{ docs: Array<{ id: string | number }> }>;
  update: (args: {
    collection: string;
    id: string | number;
    data: Record<string, unknown>;
    overrideAccess: true;
  }) => Promise<unknown>;
  delete: (args: {
    collection: string;
    where: Record<string, unknown>;
    overrideAccess: true;
  }) => Promise<unknown>;
}>;

/**
 * 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<void> {
    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<void> {
    // Implemented in Phase 3.
    throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
  }
}
  • Step 4: Run → PASS

  • Step 5: Commit

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:

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:

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<void> {
    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<void> {
    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

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:

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<IAuditLog>(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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
    expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog);
  });

  it("returns NoopAuditLog when sinks=[]", () => {
    const container = new Container();
    bindAudit(container, { sinks: [] });
    const auditLog = container.get<IAuditLog>(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<IAuditLog>(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:

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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);

  return { auditLog };
}
  • Step 4: Run → PASS

  • Step 5: Commit

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:

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
pnpm --filter @repo/core-testing test recording-audit-log.test
  • Step 3: Implement

Create packages/core-testing/src/instrumentation/recording-audit-log.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<void> {
    this.recorded.push(entry);
  }

  async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
    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:

export { RecordingAuditLog } from "./recording-audit-log";
  • Step 6: Commit
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:

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
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries

Expected: all green.

  • Step 3: Commit
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:

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:

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

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:

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
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:

import { pseudonymize } from "./pseudonymize";

// ... existing class ...

  async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
    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

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:

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:

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:

export {
  createAuditErasureHook,
  type AuditErasureHookOpts,
} from "./audit-erasure-hook";
  • Step 5: Run → PASS

  • Step 6: Commit

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:

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<TrpcContext>().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:

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:

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<void> } }).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:

export { auditRouter, type AuditRouter } from "./integrations/api/router";
export { createAuditErasureHook, type AuditErasureHookOpts } from "./hooks";
export { pseudonymize } from "./pseudonymize";
  • Step 7: Commit
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
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:

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<void>((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
pnpm --filter @repo/core-shared test current-trace-id.test
  • Step 3: Implement

Create packages/core-shared/src/instrumentation/otel/current-trace-id.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:

export { currentTraceId } from "./current-trace-id";

Append to packages/core-shared/src/instrumentation/index.ts:

export { currentTraceId } from "./otel/current-trace-id";
  • Step 6: Commit
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:

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<void>((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<void>((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:

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<void> {
    if (entry.correlationId) {
      return this.inner.record(entry);
    }
    const traceId = currentTraceId();
    if (!traceId) {
      return this.inner.record(entry);
    }
    return this.inner.record({ ...entry, correlationId: traceId });
  }

  eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
    return this.inner.eraseSubject(actorId, mode);
  }
}
  • Step 4: Run → PASS

  • Step 5: Commit

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:

  const auditLog: IAuditLog =
    sinks.length > 1 ? new MultiSinkAuditLog(sinks)
    : sinks.length === 1 ? sinks[0]!
    : new NoopAuditLog();

Replace with:

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:

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
pnpm --filter @repo/core-audit test bind-audit.test
  • Step 4: Update package barrel

Modify packages/core-audit/src/index.ts to add:

export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
  • Step 5: Phase 4 verification gate
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries

Expected: all green.

  • Step 6: Commit
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:

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:

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<string, string | string[] | undefined> };
  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<string, string | string[] | undefined> };
  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:

export {
  createAuditAfterReadHook,
  type AuditAfterReadHookOpts,
} from "./audit-after-read-hook";

Modify packages/core-audit/src/index.ts to add:

export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
  • Step 6: Phase 5 verification gate
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
  • Step 7: Commit
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

head -100 docs/decisions/adr-017-opentelemetry-migration.md
  • Step 2: Write the ADR

Create docs/decisions/adr-018-audit-and-compliance.md:

# 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
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):

# 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
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

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
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
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

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:

// 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:

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:

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<string, Record<string, string>>;
  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
pnpm --filter @repo/turbo-generators test core-package-audit.e2e

Expected: PASS (~30-60s).

  • Step 5: Commit
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:

| 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:

| `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:

- `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:

pnpm turbo gen core-package audit     # DPA-compliant audit logging (ADR-018)
  • Step 7: Commit
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
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries

Expected: all green.

  • Step 2: Confirm e2e test passes
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.