docs(spec): audit logging & DPA compliance design

Six-phase design for @repo/core-audit (5th optional package).

Phase 1: AuditLogProtocol + AuditEntry type + truncateIp helper in
  core-shared; BindContext.auditLog? optional field.
Phase 2: @repo/core-audit package with NoopAuditLog + PayloadAuditLog
  (append-only collection) + StdoutJsonAuditLog (structured JSON) +
  MultiSinkAuditLog fan-out wrapper + RecordingAuditLog in core-testing.
Phase 3: GDPR erasure plumbing — eraseSubject impls, pseudonymize helper
  (sha256-with-salt), admin tRPC procedure, createAuditErasureHook
  Payload afterDelete hook factory.
Phase 4: OTel correlation bridge — currentTraceId() helper,
  TraceIdEnrichingAuditLog decorator wraps inner sinks at bind time so
  AuditEntry.correlationId auto-populates from active OTel span.
Phase 5: createAuditAfterReadHook factory for opt-in per-collection
  automatic VIEW capture; reference wiring documented (printed by
  generator as a diff, NOT auto-installed in auth).
Phase 6: ADR-018, audit-and-compliance.md guide, generator template
  + byte-identical snapshot + e2e test, doc refreshes (CLAUDE.md,
  AGENTS.md, template-tiers, data-flow-explainer, README,
  scaffolding-doc).

Compliance grounded in DPA "Logging & Monitoring" requirements:
closed action enum (VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE),
required tenant field, type-enforced "what NOT to log" (no payload/
body/oldValue/newValue fields), IP /24 v4 + /48 v6 truncation, sha256-
salted pseudonymization, append-only Payload collection with privileged
overrideAccess erasure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 15:46:33 +02:00
parent 726934bdeb
commit 40b5b1a94f

View File

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