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>
25 KiB
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
AuditLogProtocolincore-shared/di/bind-protocols.ts— minimal{ record(entry): Promise<void> }. Features depend oncore-shared, never on optional@repo/core-audit.IAuditLog(in the optional package) extends the protocol with the privilegederaseSubject(actorId, mode).AuditEntrytype incore-shared/src/audit/audit-entry.ts— closed action enum, requiredtenantfield (single-tenant projects use"default"), requiredcontainsPiiflag, no payload-shaped fields by construction.truncateIp(raw)helper incore-shared/src/audit/truncate-ip.ts— enforces /24 IPv4 + /48 IPv6 truncation; throws on malformed input.BindContext.auditLog?field — 5th genericAudit extends AuditLogProtocol = AuditLogProtocol. Backward-compatible with existing 4-generic callers.@repo/core-auditoptional package —IAuditLog,NoopAuditLog,PayloadAuditLog(local cache; append-only collection),StdoutJsonAuditLog(structured JSON to stdout for log-shipper consumption),MultiSinkAuditLog(fan-out wrapper),RecordingAuditLog(incore-testing).- Append-only Payload collection —
update: () => falseaccess rule; erasure usesoverrideAccess: truefor privileged ops. - GDPR erasure plumbing —
eraseSubject(actorId, mode: "pseudonymize" | "delete"). Pseudonymize replaces actorId witherased-{sha256(salt+id)[0:16]}. Salt fromAUDIT_PSEUDONYM_SALTenv (validated in production binding). - Erasure trigger surface — admin tRPC procedure (
audit.eraseSubject), PayloadafterDeletehook factory (createAuditErasureHook), auth integration via printed generator next-steps (NOT auto-installed). - OTel correlation bridge —
currentTraceId()helper incore-shared/instrumentation/otel/;TraceIdEnrichingAuditLogdecorator at bind time auto-populatesAuditEntry.correlationIdfrom the active OTel span; explicit caller value wins. - VIEW capture via BOTH patterns — use-case
record()calls (developer decides per-read-path) ANDcreateAuditAfterReadHookPayload hook factory (opt-in per-collection automatic capture). Fire-and-forget semantics for hooks; sink failures emit structured errors to stderr. - IP/UA explicit at call sites — no
AsyncLocalStorage. Callers gather IP+UA from the request, calltruncateIp(), pass intorecord({ from: { ipTruncated, userAgent } }). Sentinels for non-HTTP context:"system"/"background-job". - 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:
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:
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:
/**
* 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:
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(addAuditLogProtocol) - 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
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,resourceIdchangedFields(json),scopeFeature,scopeEnvironment,scopeTenantreason,correlationId,requestId,ipTruncated,userAgentcontainsPii(checkbox),piiCategories(json),outcome(enum),errorCode- Payload's built-in
createdAtmaps toAuditEntry.at
Indexed columns: actorId, action, resourceType, scopeFeature, scopeTenant, correlationId.
5.4 Impls
NoopAuditLog— both methods no-op.PayloadAuditLog— callspayload.create({ collection: "audit-logs", data }).eraseSubjectimpl 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.allSettledover inner sinks; failures emit_type: "audit-sink-error"to stderr (avoids recursion via Sentry/OTel).RecordingAuditLog(incore-testing) — collectsrecorded[]anderasures[]. Mirrors Payload semantics ineraseSubjectfor test fidelity.
5.5 bindAudit binder
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
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.findwithoverrideAccess: true), iterate andpayload.update({ id, data: { actorId: pseudonym }, overrideAccess: true }). Bypassesupdate: () => falsevia the documented Payload escape hatch.
StdoutJsonAuditLog.eraseSubject emits the tombstone (described in Phase 2).
6.3 createAuditErasureHook Payload hook factory
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
// 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
// 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
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 withTraceIdEnrichingAuditLog) - 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+resourceTypeat hook-creation time — declared per-collection, propagated to every entry. shouldSkippredicate — 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:
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:
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:- What DPA requires (summary of the compliance doc)
- Two-pattern model (use-case calls vs Payload hooks)
- When to use which pattern
- Wiring core-audit into your app (mirrors the print)
- Sample Vector + Fluent Bit configs (stdout → Grafana Loki EU)
- GDPR erasure via admin tRPC
- Sample-week audit verification ("who accessed any given record")
- Hostile-actor immutability test
- Common mistakes
docs/architecture/template-tiers.md— addcore-auditrow to the optional tabledocs/scaffolding/core-package-generator.md— addauditrow to the templates tableCLAUDE.md— Project Overview lists 5 optionals (realtime/events/trpc/ui/audit); "Read first" addsdocs/guides/audit-and-compliance.mdwith prerequisite noteAGENTS.md— package tags list adds@repo/core-audit(optional); generator list mentions audit alongside the existing fourdocs/architecture/data-flow-explainer.html— audit-layer arrows marked conditional/optionalREADME.md— Optional packages block addspnpm 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 separatevalueDifffield). - 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 —
IAuditLogextension only. - OTel Logs API integration. Audit is a parallel channel, NOT a signal flowing through OTel (different durability + retention contract). The
correlationIdfield 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 (
auditLogscollection) 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)