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