§ 01

## Why two channels.

Audit and OTel both observe what the system did, but they answer different questions and serve different masters. Audit is the durable, compliance-bound record of who did what to whose data, with strict redaction rules and a privileged erasure path. OTel is best-effort observability for engineers — sampled, lossy, and pruned. The two channels share one bridge: the OTel `traceId` lives in the audit entry as `correlationId`, so a compliance auditor finding a suspicious VIEW can pivot to the full distributed trace, and an engineer debugging an incident can pivot to the audit record of who triggered it.

durable · retained · compliance-bound

### Audit channel.

Every personal-data access produces one entry. The entry is append-only — no UPDATE path, no DELETE path except the privileged GDPR erasure. Retained for years (jurisdiction-dependent; 7 is common in EU). Sampled at 100%; nothing is dropped. The redaction contract is enforced by the type: there is no field on `AuditEntry` where you could put a value that would later be regretted.

Consumed by compliance auditors, DPOs, and security incident response. The audience never debugs application code — they answer "did user X access record Y, and when?".

best-effort · sampled · engineering

### OTel channel.

Spans, log records, and metrics from every code path that matters to engineering. Head-sampled (typically 5–10% of traces); dropped under load; pruned at 30–90 days. PII-scrubbed at the OTel processor layer before exporters see anything (R31–R36, ADR-017 §7). Sentry is the exporter; the SDK is the substrate.

Consumed by engineers debugging incidents, SREs watching latency dashboards, and the on-call rotation. The audience never asks "did this person have permission" — they answer "why is the p99 spiking".

dimension audit OTel observability
retention Years (DPA-bound, often 7 years EU) Days to months, pruned by index policy
sampling 100% — every event captured Head-sampled (5–10% typical)
mutability Append-only; `update: () => false` Read-only after ingest; pruned by retention
erasure Privileged GDPR path (`overrideAccess`) Time-based eviction only
PII posture Closed schema; type forbids payloads/values Scrubbed at processor layer; `sendDefaultPii: false`
audience Compliance, DPO, SecIR Engineering, SRE, on-call
bridge field `correlationId` = OTel trace ID Span `traceId` matches audit's `correlationId`
failure mode Stderr report; never block the request Drop silently; observability is best-effort

Why not one channel? The pressures pull in opposite directions. Observability wants to be cheap, samplable, and disposable — those are the levers engineers reach for under load. Compliance wants the exact opposite: lossless, immutable, retained beyond your tenure. Routing audit through OTel would force one side to compromise the other; the parallel channel lets each be uncompromised on its own axis, with the `correlationId` bridge giving you the only pivot you actually need.

§ 02

## The AuditEntry shape.

One type, six logical groups: WHO / WHAT / WHEN / WHERE (scope) / WHY / OUTCOME, with a FROM fragment per DPA. The shape is closed by design — no `payload`, no `body`, no `oldValue`, no `newValue`. The action enum is finite; new action types require an explicit type bump that compliance reviewers can sample. `subject.id`-equivalents are pseudonymized via salted sha256 on erasure. IPs are truncated to /24 (v4) or /48 (v6) at the call site. The `correlationId` arrives at sink time via decorator.

    /**
     * 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. Non-HTTP contexts use
     * sentinels: { 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.
     */
    export type AuditEntry = {
      // WHO ───────────────────────────────────────────────────────────
      actorId: string;       // user id, or "system"/"service-{name}". NEVER email or name (R36)
      actorType: "user" | "system" | "service";
      actorRoles: string[];   // snapshot AT TIME OF ACTION — historical state preserved

      // WHAT ──────────────────────────────────────────────────────────
      action: AuditAction;
      resource: { type: string; id?: string };
      changedFields?: string[];  // UPDATE only: NAMES, never values

      // WHEN ──────────────────────────────────────────────────────────
      at: Date;             // server time; sinks serialize as ISO 8601

      // SCOPE (where) ─────────────────────────────────────────────────
      scope: {
        feature: string;
        environment: string;
        tenant: string;       // REQUIRED. Single-tenant projects pass "default".
      };

      // WHY ───────────────────────────────────────────────────────────
      reason?: string;
      correlationId?: string;  // auto-populated by TraceIdEnrichingAuditLog at sink time
      requestId?: string;

      // FROM (per DPA) ────────────────────────────────────────────────
      from: AuditFrom;

      // PII CLASSIFICATION ────────────────────────────────────────────
      containsPii: boolean;
      piiCategories?: string[];

      // OUTCOME ───────────────────────────────────────────────────────
      outcome: "success" | "denied" | "error";
      errorCode?: string;
    };

Read the absences. What's not here is the design. There's no `requestBody`, no `oldArticle`, no `newPassword`, no `changedValues`. UPDATE captures the names of fields that changed via `changedFields`, never their before-or-after values. The audit log is a record of events, not a secondary store of regulated content. If you want to know what an article's title used to be, that's what versioning is for — and versioning lives in the application layer, where its retention can be tuned independently of the compliance log.

    import { truncateIp } from "@repo/core-shared/audit";

    // Truncation contract — hard failure on malformed input.
    truncateIp("192.168.1.42");                 // → "192.168.1.0"   (/24)
    truncateIp("2001:0db8:1234:5678:abcd::");    // → "2001:0db8:1234::" (/48)
    truncateIp("not-an-ip");                    // → throws — compliance regimes prefer hard failures

§ 03

## Two integration patterns.

Audit can land at the use-case layer (the developer decides per-read-path; full request context available) or at the Payload collection boundary (one install line per collection; every read captured automatically). Both are first-class; production deployments under DPA scope use both for the same collection. The use-case call captures the reason a request happened; the hook captures that the system saw the doc.

pattern A · authoritative actions

#### Use-case record() call.

Sign-in, role change, content publish, billing event — every authoritative action a use case represents calls `ctx.auditLog?.record({...})` directly. The use case has the full request context (actor, roles, IP, UA) and knows the why (reason). Fully synchronous: the caller awaits the record and the entry lands before the response goes out.

Best when you control the call site and you need `reason` attached to the entry.

pattern B · data-access at the boundary

#### Payload afterRead / afterDelete hook.

`createAuditAfterReadHook(...)` on a collection's `hooks.afterRead` array captures every read of the collection — admin UI, programmatic REST, direct API — without per-use-case instrumentation. `createAuditErasureHook(...)` on `hooks.afterDelete` triggers the GDPR pseudonymization path automatically when a subject document is deleted.

Fire-and-forget: a failing sink emits to stderr but never blocks the read.

    export function getArticleUseCase(
      deps: { articlesRepo: IArticlesRepository; auditLog?: AuditLogProtocol },
    ) {
      return async (input: GetArticleInput): Promise<GetArticleOutput> => {
        const article = await deps.articlesRepo.findById(input.id);
        await deps.auditLog?.record({
          actorId: input.userId,
          actorType: "user",
          actorRoles: input.userRoles,
          action: "VIEW",
          resource: { type: "articles", id: input.id },
          at: new Date(),
          scope: { feature: "blog", environment: process.env.NODE_ENV ?? "development", tenant: input.tenant ?? "default" },
          from: { ipTruncated: input.ipTruncated, userAgent: input.userAgent },
          containsPii: false,
          outcome: "success",
          reason: "article-page-render",    // only pattern A has reason
        });
        return getArticleOutputSchema.parse(article);
      };
    }

    import { createAuditAfterReadHook } from "@repo/core-audit/hooks";

    export const articlesCollection: CollectionConfig = {
      slug: "articles",
      hooks: {
        afterRead: [
          createAuditAfterReadHook({
            auditLog: ctx.auditLog,
            resourceType: "articles",
            feature: "blog",
            environment: process.env.NODE_ENV ?? "development",
            resolveTenant: () => "default",
            containsPii: false,
          }),
        ],
      },
      // ... fields ...
    };

read source recommended pattern
tRPC procedure (app-facing read) pattern A — full request context, attach `reason`
Payload admin UI pattern B — no request context to thread
Background job / cron pattern A — `actorId: "system"`, sentinel IP/UA
Direct programmatic / CMS REST pattern B — boundary captures it for you
CLI / seed script pattern A — `actorId: "service-{name}"`
Collection under DPA scope use BOTH — belt and suspenders
§ 04

## Sinks & composition.

Four sink implementations and one decorator. `NoopAuditLog` is the slim-template default; real deployments wire `StdoutJsonAuditLog` and `PayloadAuditLog` behind a `MultiSinkAuditLog` fan-out. `TraceIdEnrichingAuditLog` wraps the chosen inner sink to populate `correlationId` automatically. Failures in any one sink emit to stderr — never through OTel, because routing audit-sink errors back through Sentry would create a recursion loop if Sentry itself were one of the failing sinks.

NoopAuditLog
default · slim template

No-op stub. Used when `@repo/core-audit` hasn't been scaffolded. The `record()` calls in use cases are guarded by `ctx.auditLog?.record(...)`, so this is reachable; calling it just resolves to `undefined`.

PayloadAuditLog
local cache · append-only collection

Writes one row per entry to the `audit-logs` Payload collection. The collection enforces `update: () => false` and `delete: () => false` — the compliance backbone. Queryable from Payload admin; useful for the in-app compliance UI.

StdoutJsonAuditLog
ship via Vector / Fluent Bit → Loki / ES

One JSON line per entry to `process.stdout`, prefixed by `"_type": "audit"`. A log shipper grep-filters by `_type` and forwards to Grafana Loki, Elastic, or Splunk. The aggregator becomes the authoritative archive (independent of Payload's DB).

MultiSinkAuditLog
fan-out · Promise.allSettled

Wraps `[payload, stdout]` (default) or any composition. Calls every inner sink in parallel with `Promise.allSettled`; one failing sink doesn't drop the entry from others. Rejections emit a `_type: "audit-sink-error"` line to stderr.

TraceIdEnrichingAuditLog
decorator · outermost · applied at bind time

Wraps the chosen inner sink. On every `record(entry)`, if `entry.correlationId` is not already set, reads `currentTraceId()` from the active OTel span and assigns it. Caller-supplied `correlationId` always wins — explicit beats implicit. Single source of truth for the OTel-audit bridge; no feature code knows the trace API.

    export function bindAudit(
      container: Container,
      opts: BindAuditOpts = {},
    ): { auditLog: IAuditLog } {
      // Fail fast: production without a salt refuses to start.
      if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) {
        throw new Error("AUDIT_PSEUDONYM_SALT environment variable is required in production.");
      }

      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());
      }

      // 1. Pick fan-out vs single vs noop.
      const inner: IAuditLog =
        sinks.length > 1 ? new MultiSinkAuditLog(sinks)
        : sinks.length === 1 ? sinks[0]!
        : new NoopAuditLog();

      // 2. Wrap once with the OTel-bridge decorator — every sink sees correlationId.
      const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);

      container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
      return { auditLog };
    }

Why stderr for sink failures, not OTel? If one of the sinks is the OTel/Sentry exporter (or shares its infrastructure), routing the failure back through Sentry's `captureException` would create a recursion loop: Sentry fails, we report to Sentry, that report fails, we report again. Stderr breaks the loop and is consumed by the same log shipper as audit entries themselves, so the operator sees `_type: "audit-sink-error"` right next to `_type: "audit"` in the same aggregator.

§ 05

## The correlation bridge.

A request enters; an OTel span opens; the use case calls `auditLog.record(...)`; `TraceIdEnrichingAuditLog` reads `currentTraceId()` from the active span context and stamps the entry's `correlationId`. The inner sink then writes durably. The same `traceId` now lives in two stores with different fates — the OTel trace will be sampled and pruned within weeks; the audit entry will live for years. The bridge is one field; the pivot is one click.

step 01 · transport
HTTP request enters
tRPC procedure receives input; controller resolves through DI
↓
step 02 · instrumentation
OTel span starts
`tracer.startSpan({ name: "blog.getArticle" })` · traceId = 0123abcd... · attaches to active context
↓
step 03 · use case
getArticleUseCase(deps)(input)
repository read returns the article; then the audit call fires
↓
step 04 · audit
auditLog.record({ ..., correlationId: undefined })
caller does NOT supply correlationId — explicit beats implicit, but absence yields to the decorator
↓
step 05 · the bridge
TraceIdEnrichingAuditLog.record(entry)
reads `currentTraceId()` = 0123abcd... · spreads into entry · forwards to inner sink
↓
step 06 · durability
MultiSinkAuditLog → [Payload row, stdout JSON line]
both sinks see entry.correlationId = 0123abcd... · fan-out runs in parallel
OTel fate
traceId 0123abcd
Span exported to Sentry via `SentrySpanProcessor`. Subject to head sampling (5–10% kept). Retained 30–90 days. PII-scrubbed at the processor layer. Engineering audience.
audit fate
correlationId 0123abcd
Entry persisted in append-only Payload row + shipped to Loki/Elastic via stdout. 100% capture, no sampling. Retained 7 years. Erasable only via the privileged GDPR path. Compliance audience.

    export class TraceIdEnrichingAuditLog implements IAuditLog {
      constructor(readonly inner: IAuditLog) {}

      async record(entry: AuditEntry): Promise<void> {
        if (entry.correlationId) return this.inner.record(entry);     // explicit wins
        const traceId = currentTraceId();
        if (!traceId) return this.inner.record(entry);             // no active span; pass through
        return this.inner.record({ ...entry, correlationId: traceId }); // stamp + forward
      }
    }

The pivot. When the compliance team flags a suspicious VIEW on a regulated record, the audit entry's `correlationId` lands directly in Grafana Tempo / Jaeger / Sentry's trace search. The engineer sees the full request — every span, every log record, every captured exception — exactly as the user experienced it. Conversely, when an engineer debugging a 500 traces it back to a sign-in flow, the same `traceId` queries Loki for `{job="audit"} | json | correlationId="0123abcd"` and surfaces who, what, when, from where. One field, two channels, both worlds queryable.

§ 06

## GDPR erasure — the privileged path.

Article 17 ("right to erasure") requires that a data subject can request deletion of their personal data. The audit log is the one collection where deletion is not a normal API operation — append-only is the compliance backbone. The erasure path bypasses that backbone via `overrideAccess: true` on Payload. It is the only path that can; it lives behind an admin tRPC procedure and an optional Payload `afterDelete` hook. Two modes: pseudonymize (default — preserve the event, erase the identity) or delete (hard-remove every entry for an actor).

    /**
     * Produces a stable, irreversible token for a GDPR-erased actorId.
     * Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
     *
     * The salt is AUDIT_PSEUDONYM_SALT; bindAudit() refuses to start in
     * production if the var is not set. Dev fallback is labelled so any
     * token produced with it is recognisable as a non-production artefact.
     */
    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)}`;
    }

    async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
      const payload = await getPayload({ config: this.config });
      const { docs } = await payload.find({
        collection: "audit-logs",
        where: { actorId: { equals: actorId } },
        overrideAccess: true,  // ← THE only privileged path
        limit: 0,
      });

      if (mode === "delete") {
        for (const d of docs) {
          await payload.delete({ collection: "audit-logs", id: d.id, overrideAccess: true });
        }
        return;
      }

      // pseudonymize: preserve the event, replace the identity.
      const token = pseudonymize(actorId);
      for (const d of docs) {
        await payload.update({
          collection: "audit-logs",
          id: d.id,
          data: { actorId: token },
          overrideAccess: true,                       // ← bypass update: () => false
        });
      }
    }

Two ways to invoke it. First, an admin tRPC procedure at `audit.eraseSubject` — protected by your admin auth middleware; a DPO can invoke it from a request-handling UI or directly via the API. Second, the `createAuditErasureHook` Payload `afterDelete` hook on the users collection — when a user is deleted through normal Payload flow, the hook triggers `pseudonymize` automatically. Both reach the same `IAuditLog.eraseSubject` method; the hook is the ergonomic default for the "delete account" flow, the tRPC procedure is the explicit-request path.

What the stdout sink can't do. `StdoutJsonAuditLog.eraseSubject` writes a tombstone line (`_type: "audit-erasure"`) to stdout — that's all it can do. Past stdout lines have already left the building; they're in Loki / Elastic / Splunk, on disk, in backups. The tombstone informs the operator and the downstream aggregator. The actual erasure in the aggregator is the operator's responsibility — typically a Loki `/loki/api/v1/delete` call with the `{actorId="user_123"}` label selector, run after the Payload pseudonymization completes. Out of scope for this package; documented in the guide.

§ 07

## The wiring path.

After `pnpm turbo gen core-package audit` scaffolds the package, the generator prints seven manual wiring steps. They mirror the existing optional-package install pattern (events, jobs, realtime) — same resolve-step in `bindAll()`, same context-object passing convention. The `auditLog` field is added to `BindProductionContext` as optional (R51-style), so features that don't use it ignore it without breaking compile.

    export async function bindAll(): Promise<void> {
      // Rule 0 (independent of repo binding mode):
      const { tracer, logger } = await resolveInstrumentation();

      // Then the optional resolves (each gated on its package being scaffolded):
      const { bus, queue } = await resolveEventsAndJobsProduction(config);
      const { realtime, realtimeRegistry } = await resolveRealtime();
      const { auditLog } = bindAudit(sharedContainer, {     // ← NEW
        payloadConfig: config,
        sinks: ["payload", "stdout"],
      });

      // Build one ctx object, pass to every feature binder:
      const ctx: BindProductionContext = {
        tracer, logger, config, bus, queue, realtime, realtimeRegistry,
        auditLog,                                                // ← NEW
      };

      await bindProductionAuth(ctx);
      await bindProductionBlog(ctx);
      // ... every feature binder receives the same ctx.
    }

#### Set AUDIT_PSEUDONYM_SALT.

`openssl rand -hex 32` into your secrets manager. Required in production; `bindAudit()` throws at startup if absent (intentional fail-fast — better to refuse to boot than to use a predictable dev-fallback salt for real subject pseudonymization).

#### Mount the audit-logs Payload collection.

Add `auditLogsCollection` from `@repo/core-audit/collection` to the `collections` array in `packages/core-cms/src/payload.config.ts`. The collection enforces `update: () => false` and `delete: () => false` — the compliance backbone.

#### Mount the admin tRPC router.

In `packages/core-api/src/root.ts`, wire `createAuditRouter(auditLog)` from `@repo/core-audit/api`. Exposes `audit.eraseSubject` as an admin-only mutation; protect with your admin auth middleware.

#### Call bindAudit() in app bootstrap.

Inside the app's `bindAll()` (or its production sub-step), call `bindAudit(sharedContainer, { payloadConfig, sinks: ["payload", "stdout"] })` and add the returned `auditLog` to the `ctx` object passed to every feature binder.

#### Install user-collection hooks (DPA recommended).

In `packages/auth/src/di/bind-production.ts`, guarded by `if (ctx.auditLog)`, push `createAuditErasureHook` onto the users collection's `afterDelete` array and optionally `createAuditAfterReadHook` onto `afterRead`. Manual install (not auto-wired) because of the cross-package coupling.

#### Set up a log shipper.

Deploy Vector or Fluent Bit alongside your app container. Filter on `_type: "audit"`; forward to Grafana Loki / Elastic / Splunk. Sample configs are in the guide. The aggregator becomes the authoritative archive (independent of Payload's DB).

#### Verify.

`pnpm install && pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries`. Then trigger a VIEW event in dev mode and confirm an `_type: "audit"` JSON line appears on stdout within 100 ms. Run the hostile-actor immutability test from the guide before go-live.

§ 08

## DPA — what NOT to log.

The compliance posture is enforced by the type system and a small set of conventions, not by reviewers reading every PR. Each row of this table answers two questions: why is this forbidden and what mechanism prevents it. The mechanism is almost always one of four: closed enum (no free strings); type shape (no field exists where the value could land); helper-enforced truncation (function call refuses malformed input); or convention with a CI grep gate (R31, `sendDefaultPii: false`).

forbidden field why mechanism
payload / body Would mirror regulated content into a long-retention store. Audit logs become a secondary PII source — worse than the original because retention is longer. Type shape — no field exists
oldValue / newValue Captures the before-or-after content of a regulated field. UPDATE actions track `changedFields` (NAMES) only — versioning lives elsewhere. Type shape — no field exists
actor email / name Identifies the actor without pseudonymization. GDPR erasure could not honour a deletion request because we'd have shipped the name into Loki / Elastic. Convention — `actorId` is doc id only; R36
raw IP address Full IPv4 / IPv6 is itself personal data in EU jurisdictions. Truncation reduces granularity to a network segment, sufficient for incident response. Helper — `truncateIp` throws on malformed
arbitrary action strings Free-string actions defeat compliance sampling — auditors can't enumerate the action enum. New actions require explicit type bumps + reviewer attention. Closed enum `AuditAction`
session tokens / API keys Capturing the credential the actor used would compound the leak if the audit store is itself breached. Identifiers go in `requestId`, not the credential. Convention — only `actorId` + `requestId`
implicit tenant Multi-tenancy must be expressed even in single-tenant projects (with the `"default"` sentinel) so that any later split is mechanical, not archaeological. Type — `scope.tenant` is required
defaultPII via SDK Sentry's `sendDefaultPii: true` would ship request bodies, headers, and user identifiers automatically — the opposite of the audit posture. CI grep gate · R31

### Compliance posture by construction.

Most fields you'd be tempted to add to an audit entry don't exist on `AuditEntry`. That's not an oversight — it's the posture. Reviewers reading PRs catch only the mistakes that compile. `payload`, `body`, `oldValue`, `newValue`: these never compile, so they never ship. `truncateIp` throws on malformed input rather than silently scrubbing — compliance regimes prefer hard failures.

The append-only guarantee is enforced at three layers in defense-in-depth: Payload's `update: () => false` access rule on the collection (in-app); the absence of an UPDATE method on the `IAuditLog` protocol (the only erasure verb is the privileged `eraseSubject`); and the log shipper writing immutably to an aggregator the application has no write credentials for. If a hostile actor gains DB access and truncates the `audit_logs` table, the shipped lines in Loki / Elasticsearch remain as the authoritative record.

The correlationId bridge gives compliance and engineering teams a shared vocabulary without coupling their tools or their retention policies. The bridge is one field; the channels stay separate; the pivot is one click. Two channels, one bridge. That's the whole design.