From b76483c4f263cec958beae5df78e45d9441228c9 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 17:15:12 +0200 Subject: [PATCH] docs(architecture): audit-and-compliance HTML explainer + sibling crosslinks --- .../audit-and-compliance-explainer.html | 1548 +++++++++++++++++ docs/architecture/data-flow-explainer.html | 5 + docs/architecture/di-explainer.html | 1 + 3 files changed, 1554 insertions(+) create mode 100644 docs/architecture/audit-and-compliance-explainer.html diff --git a/docs/architecture/audit-and-compliance-explainer.html b/docs/architecture/audit-and-compliance-explainer.html new file mode 100644 index 0000000..050f219 --- /dev/null +++ b/docs/architecture/audit-and-compliance-explainer.html @@ -0,0 +1,1548 @@ + + + + + +audit-and-compliance / template-vertical / explainer + + + + + + + +
+
+ template-vertical / audit-and-compliance / explainer + 2026-05-11 · ADR-018 +
+ +
+

Audit & compliance —
the parallel channel
to OTel.

+

A manuscript companion to ADR-018. Audit logging is a separate channel from observability — different durability, different redaction, different retention — bridged by a single correlationId field. This page walks through why two channels, how the entry is shaped, where it lands, and how GDPR erasure threads through the privileged path.

+
+ + +
+ +
+ + +
+
+
§ 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.

+
+
+ +
+ + + + + + + diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index c095fe9..01c4d09 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -2543,6 +2543,11 @@ const wrappedCtrl = withSpan(