Closes the last two staleness threads from the docs audit: 1. data-flow-explainer.html had four "Plan-9" / "post-Plan-9" references in the masthead, lede, fix-up bullet, and bindDevSeed blurb. Stripped — the architecture is now just "vertical-feature". No content changes beyond the noun rename. 2. audit-and-compliance-explainer.html had a footer link to ../superpowers/specs/2026-05-11-audit-and-compliance-design.md (archived to .archive/ earlier). Replaced with a link to ADR-018, which is the durable design record. 3. data-flow-explainer.html + di-explainer.html were inter-linked with audit + conformance explainers, but had no markdown entry point — they were orphans from any guide or architecture doc. architecture/overview.md gains a new "Interactive explainers" section listing all four single-file HTML walkthroughs with one sentence each, so they're discoverable from the documented entry point. The four pages already cross-link to each other. Final state (verified by repo-wide grep): zero "Plan-N" / "Phase-N" / "docs/superpowers/" references in docs/ (excluding .archive/ which is gitignored). Legitimate `superpowers:brainstorming` skill refs in agent-first-workflow-and-conformance.md are preserved — those reference an external plugin, not a repo path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1549 lines
77 KiB
HTML
1549 lines
77 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=1280">
|
||
<title>audit-and-compliance / template-vertical / explainer</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,300..900,0..100;1,9..144,300..900,0..100&family=JetBrains+Mono:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--paper: #f4ecd8;
|
||
--paper-2: #ebe2cb;
|
||
--paper-3: #f9f3e3;
|
||
--paper-edge: #ddd0b1;
|
||
--ink: #1a1310;
|
||
--ink-2: #5b4a3c;
|
||
--ink-3: #8a7563;
|
||
--rule: rgba(26, 19, 16, 0.12);
|
||
--rule-strong: rgba(26, 19, 16, 0.28);
|
||
--accent: #7d2828;
|
||
--accent-soft: #b86a47;
|
||
--accent-2: #1f3d4a;
|
||
--accent-3: #c5912f;
|
||
--ok: #4a6a32;
|
||
--code-bg: #16110d;
|
||
--code-bg-2: #1d1814;
|
||
--code-paper: #ebe2cb;
|
||
--code-mute: #8a7563;
|
||
--code-accent: #d8a04a;
|
||
--measure: 66ch;
|
||
--col-narrow: 720px;
|
||
--col-wide: 1080px;
|
||
--col-flow: 1180px;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font-family: "Fraunces", Georgia, serif;
|
||
font-feature-settings: "ss01", "ss02";
|
||
-webkit-font-smoothing: antialiased;
|
||
font-variation-settings: "opsz" 14, "SOFT" 50;
|
||
}
|
||
|
||
body {
|
||
position: relative;
|
||
background-image:
|
||
repeating-linear-gradient(0deg, transparent 0 31px, rgba(26,19,16,0.04) 31px 32px),
|
||
radial-gradient(circle at 22% 18%, rgba(125, 40, 40, 0.05), transparent 40%),
|
||
radial-gradient(circle at 88% 84%, rgba(31, 61, 74, 0.04), transparent 40%);
|
||
}
|
||
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence baseFrequency='0.85' numOctaves='3'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.05 0 0 0 0.10 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
|
||
pointer-events: none;
|
||
opacity: 0.45;
|
||
mix-blend-mode: multiply;
|
||
z-index: 1;
|
||
}
|
||
|
||
main, header, footer { position: relative; z-index: 2; }
|
||
|
||
a {
|
||
color: inherit;
|
||
text-decoration: underline;
|
||
text-decoration-thickness: 1px;
|
||
text-decoration-color: var(--rule-strong);
|
||
text-underline-offset: 3px;
|
||
transition: text-decoration-color 0.2s ease;
|
||
}
|
||
a:hover { text-decoration-color: var(--accent); }
|
||
|
||
/* ─── Layout ─────────────────────────────────────────────────────────── */
|
||
|
||
.frame {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 0 56px;
|
||
}
|
||
|
||
/* ─── Masthead ───────────────────────────────────────────────────────── */
|
||
|
||
header.masthead {
|
||
border-bottom: 1px solid var(--rule-strong);
|
||
padding-top: 48px;
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
.folio-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-2);
|
||
border-bottom: 1px solid var(--rule);
|
||
padding-bottom: 12px;
|
||
margin-bottom: 64px;
|
||
}
|
||
|
||
.folio-row .folio-label {
|
||
display: inline-flex;
|
||
gap: 14px;
|
||
align-items: baseline;
|
||
}
|
||
.folio-row .folio-mark {
|
||
width: 9px; height: 9px;
|
||
background: var(--accent);
|
||
display: inline-block;
|
||
border-radius: 50%;
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.title-block {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 280px;
|
||
gap: 80px;
|
||
align-items: end;
|
||
padding-bottom: 56px;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
|
||
h1.title {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 144, "SOFT" 0, "wght" 380;
|
||
font-size: clamp(64px, 7.5vw, 112px);
|
||
line-height: 0.92;
|
||
letter-spacing: -0.025em;
|
||
margin: 0 0 0 -6px;
|
||
color: var(--ink);
|
||
}
|
||
h1.title em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 144, "SOFT" 100, "wght" 380;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.lede {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 18, "SOFT" 30;
|
||
font-size: 17px;
|
||
line-height: 1.55;
|
||
color: var(--ink-2);
|
||
margin: 0;
|
||
max-width: 360px;
|
||
text-wrap: pretty;
|
||
}
|
||
|
||
.contents-row {
|
||
margin-top: 36px;
|
||
padding-top: 28px;
|
||
border-top: 1px dashed var(--rule);
|
||
display: grid;
|
||
grid-template-columns: 110px 1fr;
|
||
gap: 32px;
|
||
align-items: start;
|
||
}
|
||
|
||
.contents-label {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-3);
|
||
padding-top: 4px;
|
||
}
|
||
|
||
ol.contents {
|
||
list-style: none;
|
||
margin: 0; padding: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(8, 1fr);
|
||
gap: 18px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
ol.contents li {
|
||
border-top: 1px solid var(--ink);
|
||
padding-top: 10px;
|
||
}
|
||
ol.contents a {
|
||
display: block;
|
||
text-decoration: none;
|
||
color: var(--ink);
|
||
}
|
||
ol.contents .num {
|
||
color: var(--accent);
|
||
display: block;
|
||
font-size: 11px;
|
||
margin-bottom: 6px;
|
||
letter-spacing: 0.18em;
|
||
}
|
||
ol.contents li:hover .num { color: var(--accent-2); }
|
||
|
||
/* ─── Section primitives ─────────────────────────────────────────────── */
|
||
|
||
main { padding-top: 56px; padding-bottom: 80px; }
|
||
|
||
section {
|
||
padding: 80px 0;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
|
||
.section-head {
|
||
display: grid;
|
||
grid-template-columns: 110px 1fr;
|
||
gap: 32px;
|
||
align-items: baseline;
|
||
margin-bottom: 48px;
|
||
}
|
||
|
||
.section-num {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--accent);
|
||
padding-top: 12px;
|
||
}
|
||
|
||
.section-title {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 96, "SOFT" 0, "wght" 360;
|
||
font-size: clamp(40px, 5vw, 60px);
|
||
line-height: 0.96;
|
||
letter-spacing: -0.018em;
|
||
margin: 0 0 14px;
|
||
}
|
||
.section-title em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 96, "SOFT" 100, "wght" 360;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.section-blurb {
|
||
max-width: 70ch;
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 16, "SOFT" 40;
|
||
font-size: 16.5px;
|
||
line-height: 1.6;
|
||
color: var(--ink-2);
|
||
margin: 0;
|
||
text-wrap: pretty;
|
||
}
|
||
.section-blurb + .section-blurb { margin-top: 1em; }
|
||
|
||
p { line-height: 1.6; text-wrap: pretty; }
|
||
code {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12.5px;
|
||
background: var(--paper-2);
|
||
padding: 1px 5px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ─── Code blocks ────────────────────────────────────────────────────── */
|
||
|
||
pre.code {
|
||
background: var(--code-bg);
|
||
color: var(--code-paper);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12.5px;
|
||
line-height: 1.62;
|
||
padding: 22px 24px;
|
||
border-radius: 4px;
|
||
overflow-x: auto;
|
||
margin: 0;
|
||
border: 1px solid var(--code-bg-2);
|
||
box-shadow:
|
||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||
0 14px 40px -20px rgba(20, 14, 8, 0.5);
|
||
}
|
||
|
||
pre.code .c { color: var(--code-mute); font-style: italic; }
|
||
pre.code .k { color: var(--code-accent); }
|
||
pre.code .s { color: var(--code-paper); opacity: 0.92; }
|
||
pre.code .t { color: #b3c8a6; }
|
||
pre.code .n { color: #d2b896; }
|
||
pre.code .h { background: rgba(216, 160, 74, 0.16); padding: 0 4px; border-radius: 2px; }
|
||
|
||
pre.code::before {
|
||
content: attr(data-lang);
|
||
display: block;
|
||
font-size: 10px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: #6b574a;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid rgba(216, 160, 74, 0.18);
|
||
}
|
||
|
||
/* ─── Channel comparison (§01) ───────────────────────────────────────── */
|
||
|
||
.channels-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 24px;
|
||
margin-top: 16px;
|
||
}
|
||
.channel-card {
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper-3);
|
||
padding: 30px 34px;
|
||
border-radius: 4px;
|
||
position: relative;
|
||
}
|
||
.channel-card.audit { border-color: var(--accent); }
|
||
.channel-card.otel { border-color: var(--accent-2); border-style: dashed; }
|
||
.channel-card .channel-tag {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 10px;
|
||
}
|
||
.channel-card.audit .channel-tag { color: var(--accent); }
|
||
.channel-card.otel .channel-tag { color: var(--accent-2); }
|
||
.channel-card h3 {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 48, "SOFT" 0, "wght" 380;
|
||
font-size: 32px;
|
||
line-height: 1.0;
|
||
letter-spacing: -0.015em;
|
||
margin: 0 0 12px;
|
||
}
|
||
.channel-card h3 em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 48, "SOFT" 100, "wght" 380;
|
||
}
|
||
.channel-card.audit h3 em { color: var(--accent); }
|
||
.channel-card.otel h3 em { color: var(--accent-2); }
|
||
.channel-card p {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 16, "SOFT" 40;
|
||
font-size: 15.5px;
|
||
line-height: 1.55;
|
||
color: var(--ink-2);
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
.compare-table {
|
||
margin-top: 32px;
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper-3);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.compare-head {
|
||
display: grid;
|
||
grid-template-columns: 200px 1fr 1fr;
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
padding: 14px 22px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
}
|
||
.compare-row {
|
||
display: grid;
|
||
grid-template-columns: 200px 1fr 1fr;
|
||
padding: 14px 22px;
|
||
border-top: 1px solid var(--rule);
|
||
align-items: baseline;
|
||
font-size: 14px;
|
||
}
|
||
.compare-row .dim {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.06em;
|
||
color: var(--ink);
|
||
text-transform: uppercase;
|
||
}
|
||
.compare-row .audit-col {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-size: 14px;
|
||
color: var(--accent);
|
||
}
|
||
.compare-row .otel-col {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-style: italic;
|
||
font-size: 14px;
|
||
color: var(--accent-2);
|
||
}
|
||
|
||
/* ─── Patterns (§03) ─────────────────────────────────────────────────── */
|
||
|
||
.pattern-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 28px;
|
||
margin-top: 16px;
|
||
}
|
||
.pattern-card {
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper-3);
|
||
padding: 28px 32px;
|
||
border-radius: 4px;
|
||
}
|
||
.pattern-card.use-case { border-left: 4px solid var(--accent); }
|
||
.pattern-card.hook { border-left: 4px solid var(--accent-2); }
|
||
.pattern-card .pattern-tag {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 10px;
|
||
}
|
||
.pattern-card.use-case .pattern-tag { color: var(--accent); }
|
||
.pattern-card.hook .pattern-tag { color: var(--accent-2); }
|
||
.pattern-card h4 {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 28, "SOFT" 30, "wght" 460;
|
||
font-size: 24px;
|
||
margin: 0 0 12px;
|
||
letter-spacing: -0.005em;
|
||
}
|
||
.pattern-card h4 em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 28, "SOFT" 100, "wght" 460;
|
||
}
|
||
.pattern-card.use-case h4 em { color: var(--accent); }
|
||
.pattern-card.hook h4 em { color: var(--accent-2); }
|
||
.pattern-card p {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 15, "SOFT" 40;
|
||
font-size: 14.5px; line-height: 1.55;
|
||
color: var(--ink-2); margin: 0 0 14px;
|
||
}
|
||
|
||
.when-to-use {
|
||
margin-top: 32px;
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper-3);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.when-head, .when-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
padding: 14px 22px;
|
||
}
|
||
.when-head {
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
}
|
||
.when-row {
|
||
border-top: 1px solid var(--rule);
|
||
align-items: baseline;
|
||
}
|
||
.when-row .source {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-style: italic;
|
||
font-size: 14px;
|
||
color: var(--ink-2);
|
||
}
|
||
.when-row .pattern {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ─── Sinks (§04) ────────────────────────────────────────────────────── */
|
||
|
||
.sinks-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 18px;
|
||
margin-top: 16px;
|
||
}
|
||
.sink-card {
|
||
border: 1.5px solid var(--ink);
|
||
background: var(--paper);
|
||
padding: 22px 26px;
|
||
border-radius: 4px;
|
||
}
|
||
.sink-card.noop { border-color: var(--ink-3); }
|
||
.sink-card.payload { border-color: var(--accent); }
|
||
.sink-card.stdout { border-color: var(--accent-2); }
|
||
.sink-card.multi {
|
||
border-color: var(--ok);
|
||
border-style: dashed;
|
||
}
|
||
.sink-card.enrich {
|
||
background: var(--paper-3);
|
||
border-color: var(--accent-3);
|
||
}
|
||
.sink-card .sink-kind {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
color: var(--ink);
|
||
margin-bottom: 8px;
|
||
}
|
||
.sink-card.noop .sink-kind { color: var(--ink-3); }
|
||
.sink-card.payload .sink-kind { color: var(--accent); }
|
||
.sink-card.stdout .sink-kind { color: var(--accent-2); }
|
||
.sink-card.multi .sink-kind { color: var(--ok); }
|
||
.sink-card.enrich .sink-kind { color: var(--accent-3); }
|
||
.sink-card .sink-role {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 9.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-3);
|
||
margin-bottom: 12px;
|
||
}
|
||
.sink-card p {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--ink-2);
|
||
margin: 0;
|
||
}
|
||
|
||
/* ─── Correlation diagram (§05) ──────────────────────────────────────── */
|
||
|
||
.correlation-diagram {
|
||
background: var(--paper-3);
|
||
border: 1px solid var(--rule-strong);
|
||
border-radius: 4px;
|
||
padding: 36px;
|
||
margin-top: 16px;
|
||
}
|
||
.correlation-row {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.cstep {
|
||
border: 1px solid var(--ink);
|
||
background: var(--paper);
|
||
padding: 14px 18px;
|
||
border-radius: 3px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12.5px;
|
||
position: relative;
|
||
}
|
||
.cstep.span {
|
||
border-color: var(--accent-2);
|
||
border-style: dashed;
|
||
background: #f0f4f6;
|
||
}
|
||
.cstep.audit-call {
|
||
border-color: var(--accent);
|
||
background: #fbf1de;
|
||
}
|
||
.cstep.bridge {
|
||
border-color: var(--accent-3);
|
||
border-width: 2px;
|
||
background: #fdf6e3;
|
||
}
|
||
.cstep .ctag {
|
||
font-size: 9.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-3);
|
||
margin-bottom: 4px;
|
||
}
|
||
.cstep.span .ctag { color: var(--accent-2); }
|
||
.cstep.audit-call .ctag { color: var(--accent); }
|
||
.cstep.bridge .ctag { color: var(--accent-3); }
|
||
.cstep strong {
|
||
display: block;
|
||
color: var(--ink);
|
||
font-weight: 500;
|
||
margin-bottom: 4px;
|
||
}
|
||
.cstep .what {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 13, "SOFT" 40;
|
||
font-style: italic;
|
||
font-size: 12.5px;
|
||
color: var(--ink-2);
|
||
}
|
||
.cconnector {
|
||
display: flex; justify-content: center; align-items: center;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 9.5px; letter-spacing: 0.22em;
|
||
text-transform: uppercase; color: var(--ink-3);
|
||
padding: 6px 0;
|
||
position: relative;
|
||
}
|
||
.cconnector::before, .cconnector::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 50%;
|
||
width: 1px;
|
||
height: 6px;
|
||
background: var(--rule-strong);
|
||
transform: translateX(-0.5px);
|
||
}
|
||
.cconnector::before { top: 0; }
|
||
.cconnector::after { bottom: 0; }
|
||
|
||
.fate-pair {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
margin-top: 10px;
|
||
}
|
||
.fate {
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper);
|
||
padding: 16px 20px;
|
||
border-radius: 3px;
|
||
}
|
||
.fate.audit-fate { border-left: 3px solid var(--accent); }
|
||
.fate.otel-fate { border-left: 3px solid var(--accent-2); }
|
||
.fate .fate-tag {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 9.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 6px;
|
||
}
|
||
.fate.audit-fate .fate-tag { color: var(--accent); }
|
||
.fate.otel-fate .fate-tag { color: var(--accent-2); }
|
||
.fate strong {
|
||
display: block;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12.5px;
|
||
margin-bottom: 4px;
|
||
color: var(--ink);
|
||
}
|
||
.fate .blurb {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 13, "SOFT" 40;
|
||
font-style: italic;
|
||
font-size: 13px;
|
||
color: var(--ink-2);
|
||
}
|
||
|
||
/* ─── Wiring path (§07) ──────────────────────────────────────────────── */
|
||
|
||
.wiring-steps {
|
||
counter-reset: wstep;
|
||
margin-top: 20px;
|
||
}
|
||
.wiring-step {
|
||
counter-increment: wstep;
|
||
position: relative;
|
||
padding: 18px 22px 18px 64px;
|
||
margin-bottom: 8px;
|
||
background: var(--paper-3);
|
||
border-left: 2px solid var(--accent);
|
||
border-top: 1px solid var(--rule);
|
||
border-right: 1px solid var(--rule);
|
||
border-bottom: 1px solid var(--rule);
|
||
border-radius: 0 3px 3px 0;
|
||
}
|
||
.wiring-step::before {
|
||
content: counter(wstep, decimal-leading-zero);
|
||
position: absolute;
|
||
left: 22px;
|
||
top: 18px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
letter-spacing: 0.18em;
|
||
color: var(--accent);
|
||
font-weight: 500;
|
||
}
|
||
.wiring-step h4 {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 20, "SOFT" 30, "wght" 460;
|
||
font-size: 18px;
|
||
margin: 0 0 4px;
|
||
letter-spacing: -0.005em;
|
||
color: var(--ink);
|
||
}
|
||
.wiring-step h4 em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 20, "SOFT" 100, "wght" 460;
|
||
color: var(--accent);
|
||
}
|
||
.wiring-step p {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--ink-2);
|
||
margin: 0;
|
||
}
|
||
.wiring-step code {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
background: var(--paper-2);
|
||
padding: 1px 4px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ─── DPA forbidden table (§08) ──────────────────────────────────────── */
|
||
|
||
.forbidden-table {
|
||
margin-top: 20px;
|
||
border: 1px solid var(--rule-strong);
|
||
background: var(--paper-3);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.forbidden-head {
|
||
display: grid;
|
||
grid-template-columns: 220px 1fr 280px;
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
padding: 14px 22px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
}
|
||
.forbidden-row {
|
||
display: grid;
|
||
grid-template-columns: 220px 1fr 280px;
|
||
padding: 16px 22px;
|
||
border-top: 1px solid var(--rule);
|
||
align-items: baseline;
|
||
font-size: 14px;
|
||
gap: 14px;
|
||
}
|
||
.forbidden-row .field {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
font-weight: 500;
|
||
}
|
||
.forbidden-row .why {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 14, "SOFT" 40;
|
||
font-style: italic;
|
||
font-size: 14px;
|
||
color: var(--ink-2);
|
||
}
|
||
.forbidden-row .mech {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12px;
|
||
color: var(--ok);
|
||
}
|
||
|
||
/* ─── Verdict / posture card ─────────────────────────────────────────── */
|
||
|
||
.posture-card {
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
padding: 48px 56px;
|
||
border-radius: 4px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
margin-top: 48px;
|
||
}
|
||
.posture-card::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
radial-gradient(circle at 90% 10%, rgba(125, 40, 40, 0.5), transparent 50%),
|
||
radial-gradient(circle at 5% 90%, rgba(31, 61, 74, 0.4), transparent 50%);
|
||
pointer-events: none;
|
||
}
|
||
.posture-card h3 {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 72, "SOFT" 0, "wght" 360;
|
||
font-size: 44px;
|
||
line-height: 1.0;
|
||
letter-spacing: -0.018em;
|
||
margin: 0 0 20px;
|
||
position: relative;
|
||
max-width: 18ch;
|
||
}
|
||
.posture-card h3 em {
|
||
font-style: italic;
|
||
font-variation-settings: "opsz" 72, "SOFT" 100, "wght" 360;
|
||
color: var(--accent-3);
|
||
}
|
||
.posture-card p {
|
||
font-family: "Fraunces", serif;
|
||
font-variation-settings: "opsz" 17, "SOFT" 40;
|
||
font-size: 16.5px;
|
||
line-height: 1.6;
|
||
max-width: 62ch;
|
||
margin: 0 0 14px;
|
||
position: relative;
|
||
color: rgba(244, 236, 216, 0.85);
|
||
}
|
||
.posture-card p strong { color: var(--paper); font-weight: 500; }
|
||
.posture-card code {
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 12.5px;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
color: var(--accent-3);
|
||
padding: 1px 5px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ─── Footer ─────────────────────────────────────────────────────────── */
|
||
|
||
footer {
|
||
border-top: 2px solid var(--ink);
|
||
padding: 36px 0 56px;
|
||
}
|
||
footer .foot-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 28px;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-size: 11px;
|
||
color: var(--ink-2);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
footer h5 {
|
||
font-size: 10px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
margin: 0 0 12px;
|
||
color: var(--ink-3);
|
||
font-weight: 500;
|
||
}
|
||
footer a { display: block; margin: 4px 0; }
|
||
footer .colophon {
|
||
margin-top: 36px;
|
||
padding-top: 18px;
|
||
border-top: 1px dashed var(--rule);
|
||
font-size: 10.5px;
|
||
color: var(--ink-3);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
/* ─── Responsive ─────────────────────────────────────────────────────── */
|
||
|
||
@media (max-width: 1100px) {
|
||
.frame { padding: 0 32px; }
|
||
.channels-grid, .pattern-grid, .sinks-grid, .fate-pair, .when-head, .when-row { grid-template-columns: 1fr; }
|
||
ol.contents { grid-template-columns: repeat(2, 1fr); }
|
||
.title-block { grid-template-columns: 1fr; gap: 32px; }
|
||
.compare-head, .compare-row { grid-template-columns: 1fr; gap: 4px; }
|
||
.forbidden-head, .forbidden-row { grid-template-columns: 1fr; gap: 8px; }
|
||
footer .foot-grid { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="masthead frame">
|
||
<div class="folio-row">
|
||
<span class="folio-label"><span class="folio-mark"></span> template-vertical / audit-and-compliance / explainer</span>
|
||
<span>2026-05-11 · ADR-018</span>
|
||
</div>
|
||
|
||
<div class="title-block">
|
||
<h1 class="title">Audit & compliance —<br>the <em>parallel channel</em><br>to OTel.</h1>
|
||
<p class="lede">A manuscript companion to ADR-018. Audit logging is a separate channel from observability — different durability, different redaction, different retention — bridged by a single <code>correlationId</code> 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.</p>
|
||
</div>
|
||
|
||
<div class="contents-row">
|
||
<div class="contents-label">Contents</div>
|
||
<ol class="contents">
|
||
<li><a href="#why-two"><span class="num">01</span>Why two channels</a></li>
|
||
<li><a href="#entry"><span class="num">02</span>The entry shape</a></li>
|
||
<li><a href="#patterns"><span class="num">03</span>Two patterns</a></li>
|
||
<li><a href="#sinks"><span class="num">04</span>Sinks & composition</a></li>
|
||
<li><a href="#bridge"><span class="num">05</span>Correlation bridge</a></li>
|
||
<li><a href="#erasure"><span class="num">06</span>GDPR erasure</a></li>
|
||
<li><a href="#wiring"><span class="num">07</span>Wiring path</a></li>
|
||
<li><a href="#dpa"><span class="num">08</span>What NOT to log</a></li>
|
||
</ol>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="frame">
|
||
|
||
<!-- ─── 01 ─ WHY TWO CHANNELS ─────────────────────────────────────────── -->
|
||
<section id="why-two">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 01</div>
|
||
<div>
|
||
<h2 class="section-title">Why <em>two channels</em>.</h2>
|
||
<p class="section-blurb">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 <strong>who</strong> did <strong>what</strong> to <strong>whose</strong> 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 <code>traceId</code> lives in the audit entry as <code>correlationId</code>, 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.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="channels-grid">
|
||
<div class="channel-card audit">
|
||
<div class="channel-tag">durable · retained · compliance-bound</div>
|
||
<h3>Audit <em>channel</em>.</h3>
|
||
<p>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 <code>AuditEntry</code> where you could put a value that would later be regretted.</p>
|
||
<p>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?".</p>
|
||
</div>
|
||
<div class="channel-card otel">
|
||
<div class="channel-tag">best-effort · sampled · engineering</div>
|
||
<h3>OTel <em>channel</em>.</h3>
|
||
<p>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.</p>
|
||
<p>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".</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="compare-table">
|
||
<div class="compare-head">
|
||
<span>dimension</span>
|
||
<span>audit</span>
|
||
<span>OTel observability</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">retention</span>
|
||
<span class="audit-col">Years (DPA-bound, often 7 years EU)</span>
|
||
<span class="otel-col">Days to months, pruned by index policy</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">sampling</span>
|
||
<span class="audit-col">100% — every event captured</span>
|
||
<span class="otel-col">Head-sampled (5–10% typical)</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">mutability</span>
|
||
<span class="audit-col">Append-only; <code>update: () => false</code></span>
|
||
<span class="otel-col">Read-only after ingest; pruned by retention</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">erasure</span>
|
||
<span class="audit-col">Privileged GDPR path (<code>overrideAccess</code>)</span>
|
||
<span class="otel-col">Time-based eviction only</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">PII posture</span>
|
||
<span class="audit-col">Closed schema; type forbids payloads/values</span>
|
||
<span class="otel-col">Scrubbed at processor layer; <code>sendDefaultPii: false</code></span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">audience</span>
|
||
<span class="audit-col">Compliance, DPO, SecIR</span>
|
||
<span class="otel-col">Engineering, SRE, on-call</span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">bridge field</span>
|
||
<span class="audit-col"><code>correlationId</code> = OTel trace ID</span>
|
||
<span class="otel-col">Span <code>traceId</code> matches audit's <code>correlationId</code></span>
|
||
</div>
|
||
<div class="compare-row">
|
||
<span class="dim">failure mode</span>
|
||
<span class="audit-col">Stderr report; never block the request</span>
|
||
<span class="otel-col">Drop silently; observability is best-effort</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 32px 0 0;"><strong style="color: var(--ink); font-weight: 500;">Why not one channel?</strong> 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 <code>correlationId</code> bridge giving you the only pivot you actually need.</p>
|
||
</section>
|
||
|
||
<!-- ─── 02 ─ THE ENTRY SHAPE ──────────────────────────────────────────── -->
|
||
<section id="entry">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 02</div>
|
||
<div>
|
||
<h2 class="section-title">The <em>AuditEntry</em> shape.</h2>
|
||
<p class="section-blurb">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 <code>payload</code>, no <code>body</code>, no <code>oldValue</code>, no <code>newValue</code>. The action enum is finite; new action types require an explicit type bump that compliance reviewers can sample. <code>subject.id</code>-equivalents are pseudonymized via salted sha256 on erasure. IPs are truncated to /24 (v4) or /48 (v6) at the call site. The <code>correlationId</code> arrives at sink time via decorator.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // packages/core-shared/src/audit/audit-entry.ts"><span class="c">/**
|
||
* Closed enum of audited actions per DPA. New action types require an
|
||
* explicit type bump — compliance auditors sample by enum value.
|
||
*/</span>
|
||
<span class="k">export type</span> <span class="t">AuditAction</span> =
|
||
| <span class="s">"VIEW"</span>
|
||
| <span class="s">"CREATE"</span>
|
||
| <span class="s">"UPDATE"</span>
|
||
| <span class="s">"DELETE"</span>
|
||
| <span class="s">"EXPORT"</span>
|
||
| <span class="s">"PERMISSION_CHANGE"</span>;
|
||
|
||
<span class="c">/**
|
||
* `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" }.
|
||
*/</span>
|
||
<span class="k">export type</span> <span class="t">AuditFrom</span> = {
|
||
<span class="n">ipTruncated</span>: <span class="t">string</span>;
|
||
<span class="n">userAgent</span>: <span class="t">string</span>;
|
||
};
|
||
|
||
<span class="c">/**
|
||
* 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.
|
||
*/</span>
|
||
<span class="k">export type</span> <span class="t">AuditEntry</span> = {
|
||
<span class="c">// WHO ───────────────────────────────────────────────────────────</span>
|
||
<span class="n">actorId</span>: <span class="t">string</span>; <span class="c">// user id, or "system"/"service-{name}". NEVER email or name (R36)</span>
|
||
<span class="n">actorType</span>: <span class="s">"user"</span> | <span class="s">"system"</span> | <span class="s">"service"</span>;
|
||
<span class="n">actorRoles</span>: <span class="t">string</span>[]; <span class="c">// snapshot AT TIME OF ACTION — historical state preserved</span>
|
||
|
||
<span class="c">// WHAT ──────────────────────────────────────────────────────────</span>
|
||
<span class="n">action</span>: <span class="t">AuditAction</span>;
|
||
<span class="n">resource</span>: { <span class="n">type</span>: <span class="t">string</span>; <span class="n">id</span>?: <span class="t">string</span> };
|
||
<span class="n">changedFields</span>?: <span class="t">string</span>[]; <span class="c">// UPDATE only: NAMES, never values</span>
|
||
|
||
<span class="c">// WHEN ──────────────────────────────────────────────────────────</span>
|
||
<span class="n">at</span>: <span class="t">Date</span>; <span class="c">// server time; sinks serialize as ISO 8601</span>
|
||
|
||
<span class="c">// SCOPE (where) ─────────────────────────────────────────────────</span>
|
||
<span class="n">scope</span>: {
|
||
<span class="n">feature</span>: <span class="t">string</span>;
|
||
<span class="n">environment</span>: <span class="t">string</span>;
|
||
<span class="n">tenant</span>: <span class="t">string</span>; <span class="c">// REQUIRED. Single-tenant projects pass "default".</span>
|
||
};
|
||
|
||
<span class="c">// WHY ───────────────────────────────────────────────────────────</span>
|
||
<span class="n">reason</span>?: <span class="t">string</span>;
|
||
<span class="h"><span class="n">correlationId</span>?: <span class="t">string</span></span>; <span class="c">// auto-populated by TraceIdEnrichingAuditLog at sink time</span>
|
||
<span class="n">requestId</span>?: <span class="t">string</span>;
|
||
|
||
<span class="c">// FROM (per DPA) ────────────────────────────────────────────────</span>
|
||
<span class="n">from</span>: <span class="t">AuditFrom</span>;
|
||
|
||
<span class="c">// PII CLASSIFICATION ────────────────────────────────────────────</span>
|
||
<span class="n">containsPii</span>: <span class="t">boolean</span>;
|
||
<span class="n">piiCategories</span>?: <span class="t">string</span>[];
|
||
|
||
<span class="c">// OUTCOME ───────────────────────────────────────────────────────</span>
|
||
<span class="n">outcome</span>: <span class="s">"success"</span> | <span class="s">"denied"</span> | <span class="s">"error"</span>;
|
||
<span class="n">errorCode</span>?: <span class="t">string</span>;
|
||
};</pre>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 28px 0 0;"><strong style="color: var(--ink); font-weight: 500;">Read the absences.</strong> What's <em>not</em> here is the design. There's no <code>requestBody</code>, no <code>oldArticle</code>, no <code>newPassword</code>, no <code>changedValues</code>. UPDATE captures the <em>names</em> of fields that changed via <code>changedFields</code>, never their before-or-after values. The audit log is a record of <em>events</em>, 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.</p>
|
||
|
||
<pre class="code" data-lang="typescript // call site — packages/core-shared/audit + truncate-ip" style="margin-top: 32px;"><span class="k">import</span> { <span class="n">truncateIp</span> } <span class="k">from</span> <span class="s">"@repo/core-shared/audit"</span>;
|
||
|
||
<span class="c">// Truncation contract — hard failure on malformed input.</span>
|
||
<span class="n">truncateIp</span>(<span class="s">"192.168.1.42"</span>); <span class="c">// → "192.168.1.0" (/24)</span>
|
||
<span class="n">truncateIp</span>(<span class="s">"2001:0db8:1234:5678:abcd::"</span>); <span class="c">// → "2001:0db8:1234::" (/48)</span>
|
||
<span class="n">truncateIp</span>(<span class="s">"not-an-ip"</span>); <span class="c">// → throws — compliance regimes prefer hard failures</span></pre>
|
||
</section>
|
||
|
||
<!-- ─── 03 ─ TWO PATTERNS ─────────────────────────────────────────────── -->
|
||
<section id="patterns">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 03</div>
|
||
<div>
|
||
<h2 class="section-title">Two integration <em>patterns</em>.</h2>
|
||
<p class="section-blurb">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 <em>both</em> for the same collection. The use-case call captures the <em>reason</em> a request happened; the hook captures that <em>the system saw the doc</em>.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pattern-grid">
|
||
<div class="pattern-card use-case">
|
||
<div class="pattern-tag">pattern A · authoritative actions</div>
|
||
<h4>Use-case <em>record()</em> call.</h4>
|
||
<p>Sign-in, role change, content publish, billing event — every authoritative action a use case represents calls <code>ctx.auditLog?.record({...})</code> directly. The use case has the full request context (actor, roles, IP, UA) and knows the <em>why</em> (reason). Fully synchronous: the caller awaits the record and the entry lands before the response goes out.</p>
|
||
<p>Best when you control the call site and you need <code>reason</code> attached to the entry.</p>
|
||
</div>
|
||
<div class="pattern-card hook">
|
||
<div class="pattern-tag">pattern B · data-access at the boundary</div>
|
||
<h4>Payload <em>afterRead</em> / <em>afterDelete</em> hook.</h4>
|
||
<p><code>createAuditAfterReadHook(...)</code> on a collection's <code>hooks.afterRead</code> array captures every read of the collection — admin UI, programmatic REST, direct API — without per-use-case instrumentation. <code>createAuditErasureHook(...)</code> on <code>hooks.afterDelete</code> triggers the GDPR pseudonymization path automatically when a subject document is deleted.</p>
|
||
<p>Fire-and-forget: a failing sink emits to stderr but never blocks the read.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // pattern A — get-article.use-case.ts" style="margin-top: 32px;"><span class="k">export function</span> <span class="n">getArticleUseCase</span>(
|
||
<span class="n">deps</span>: { <span class="n">articlesRepo</span>: <span class="t">IArticlesRepository</span>; <span class="n">auditLog</span>?: <span class="t">AuditLogProtocol</span> },
|
||
) {
|
||
<span class="k">return async</span> (<span class="n">input</span>: <span class="t">GetArticleInput</span>): <span class="t">Promise</span><<span class="t">GetArticleOutput</span>> => {
|
||
<span class="k">const</span> <span class="n">article</span> = <span class="k">await</span> <span class="n">deps</span>.<span class="n">articlesRepo</span>.<span class="n">findById</span>(<span class="n">input</span>.<span class="n">id</span>);
|
||
<span class="k">await</span> <span class="n">deps</span>.<span class="n">auditLog</span>?.<span class="n">record</span>({
|
||
<span class="n">actorId</span>: <span class="n">input</span>.<span class="n">userId</span>,
|
||
<span class="n">actorType</span>: <span class="s">"user"</span>,
|
||
<span class="n">actorRoles</span>: <span class="n">input</span>.<span class="n">userRoles</span>,
|
||
<span class="n">action</span>: <span class="s">"VIEW"</span>,
|
||
<span class="n">resource</span>: { <span class="n">type</span>: <span class="s">"articles"</span>, <span class="n">id</span>: <span class="n">input</span>.<span class="n">id</span> },
|
||
<span class="n">at</span>: <span class="k">new</span> <span class="t">Date</span>(),
|
||
<span class="n">scope</span>: { <span class="n">feature</span>: <span class="s">"blog"</span>, <span class="n">environment</span>: <span class="n">process</span>.<span class="n">env</span>.<span class="n">NODE_ENV</span> ?? <span class="s">"development"</span>, <span class="n">tenant</span>: <span class="n">input</span>.<span class="n">tenant</span> ?? <span class="s">"default"</span> },
|
||
<span class="n">from</span>: { <span class="n">ipTruncated</span>: <span class="n">input</span>.<span class="n">ipTruncated</span>, <span class="n">userAgent</span>: <span class="n">input</span>.<span class="n">userAgent</span> },
|
||
<span class="n">containsPii</span>: <span class="k">false</span>,
|
||
<span class="n">outcome</span>: <span class="s">"success"</span>,
|
||
<span class="h"><span class="n">reason</span>: <span class="s">"article-page-render"</span></span>, <span class="c">// only pattern A has reason</span>
|
||
});
|
||
<span class="k">return</span> <span class="n">getArticleOutputSchema</span>.<span class="n">parse</span>(<span class="n">article</span>);
|
||
};
|
||
}</pre>
|
||
|
||
<pre class="code" data-lang="typescript // pattern B — articles.collection.ts" style="margin-top: 24px;"><span class="k">import</span> { <span class="n">createAuditAfterReadHook</span> } <span class="k">from</span> <span class="s">"@repo/core-audit/hooks"</span>;
|
||
|
||
<span class="k">export const</span> <span class="n">articlesCollection</span>: <span class="t">CollectionConfig</span> = {
|
||
<span class="n">slug</span>: <span class="s">"articles"</span>,
|
||
<span class="n">hooks</span>: {
|
||
<span class="n">afterRead</span>: [
|
||
<span class="n">createAuditAfterReadHook</span>({
|
||
<span class="n">auditLog</span>: <span class="n">ctx</span>.<span class="n">auditLog</span>,
|
||
<span class="n">resourceType</span>: <span class="s">"articles"</span>,
|
||
<span class="n">feature</span>: <span class="s">"blog"</span>,
|
||
<span class="n">environment</span>: <span class="n">process</span>.<span class="n">env</span>.<span class="n">NODE_ENV</span> ?? <span class="s">"development"</span>,
|
||
<span class="n">resolveTenant</span>: () => <span class="s">"default"</span>,
|
||
<span class="n">containsPii</span>: <span class="k">false</span>,
|
||
}),
|
||
],
|
||
},
|
||
<span class="c">// ... fields ...</span>
|
||
};</pre>
|
||
|
||
<div class="when-to-use">
|
||
<div class="when-head">
|
||
<span>read source</span>
|
||
<span>recommended pattern</span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">tRPC procedure (app-facing read)</span>
|
||
<span class="pattern">pattern A — full request context, attach <code>reason</code></span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">Payload admin UI</span>
|
||
<span class="pattern">pattern B — no request context to thread</span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">Background job / cron</span>
|
||
<span class="pattern">pattern A — <code>actorId: "system"</code>, sentinel IP/UA</span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">Direct programmatic / CMS REST</span>
|
||
<span class="pattern">pattern B — boundary captures it for you</span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">CLI / seed script</span>
|
||
<span class="pattern">pattern A — <code>actorId: "service-{name}"</code></span>
|
||
</div>
|
||
<div class="when-row">
|
||
<span class="source">Collection under DPA scope</span>
|
||
<span class="pattern">use BOTH — belt and suspenders</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── 04 ─ SINKS & COMPOSITION ──────────────────────────────────────── -->
|
||
<section id="sinks">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 04</div>
|
||
<div>
|
||
<h2 class="section-title">Sinks & <em>composition</em>.</h2>
|
||
<p class="section-blurb">Four sink implementations and one decorator. <code>NoopAuditLog</code> is the slim-template default; real deployments wire <code>StdoutJsonAuditLog</code> and <code>PayloadAuditLog</code> behind a <code>MultiSinkAuditLog</code> fan-out. <code>TraceIdEnrichingAuditLog</code> wraps the chosen inner sink to populate <code>correlationId</code> automatically. Failures in any one sink emit to <strong>stderr</strong> — 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.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sinks-grid">
|
||
<div class="sink-card noop">
|
||
<div class="sink-kind">NoopAuditLog</div>
|
||
<div class="sink-role">default · slim template</div>
|
||
<p>No-op stub. Used when <code>@repo/core-audit</code> hasn't been scaffolded. The <code>record()</code> calls in use cases are guarded by <code>ctx.auditLog?.record(...)</code>, so this is reachable; calling it just resolves to <code>undefined</code>.</p>
|
||
</div>
|
||
<div class="sink-card payload">
|
||
<div class="sink-kind">PayloadAuditLog</div>
|
||
<div class="sink-role">local cache · append-only collection</div>
|
||
<p>Writes one row per entry to the <code>audit-logs</code> Payload collection. The collection enforces <code>update: () => false</code> and <code>delete: () => false</code> — the compliance backbone. Queryable from Payload admin; useful for the in-app compliance UI.</p>
|
||
</div>
|
||
<div class="sink-card stdout">
|
||
<div class="sink-kind">StdoutJsonAuditLog</div>
|
||
<div class="sink-role">ship via Vector / Fluent Bit → Loki / ES</div>
|
||
<p>One JSON line per entry to <code>process.stdout</code>, prefixed by <code>"_type": "audit"</code>. A log shipper grep-filters by <code>_type</code> and forwards to Grafana Loki, Elastic, or Splunk. The aggregator becomes the authoritative archive (independent of Payload's DB).</p>
|
||
</div>
|
||
<div class="sink-card multi">
|
||
<div class="sink-kind">MultiSinkAuditLog</div>
|
||
<div class="sink-role">fan-out · Promise.allSettled</div>
|
||
<p>Wraps <code>[payload, stdout]</code> (default) or any composition. Calls every inner sink in parallel with <code>Promise.allSettled</code>; one failing sink doesn't drop the entry from others. Rejections emit a <code>_type: "audit-sink-error"</code> line to <strong>stderr</strong>.</p>
|
||
</div>
|
||
<div class="sink-card enrich" style="grid-column: span 2;">
|
||
<div class="sink-kind">TraceIdEnrichingAuditLog</div>
|
||
<div class="sink-role">decorator · outermost · applied at bind time</div>
|
||
<p>Wraps the chosen inner sink. On every <code>record(entry)</code>, if <code>entry.correlationId</code> is not already set, reads <code>currentTraceId()</code> from the active OTel span and assigns it. Caller-supplied <code>correlationId</code> always wins — explicit beats implicit. Single source of truth for the OTel-audit bridge; no feature code knows the trace API.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // bindAudit composes the chain — packages/core-audit/src/di/bind-audit.ts" style="margin-top: 32px;"><span class="k">export function</span> <span class="n">bindAudit</span>(
|
||
<span class="n">container</span>: <span class="t">Container</span>,
|
||
<span class="n">opts</span>: <span class="t">BindAuditOpts</span> = {},
|
||
): { <span class="n">auditLog</span>: <span class="t">IAuditLog</span> } {
|
||
<span class="c">// Fail fast: production without a salt refuses to start.</span>
|
||
<span class="k">if</span> (<span class="n">process</span>.<span class="n">env</span>.<span class="n">NODE_ENV</span> === <span class="s">"production"</span> && !<span class="n">process</span>.<span class="n">env</span>.<span class="n">AUDIT_PSEUDONYM_SALT</span>) {
|
||
<span class="k">throw new</span> <span class="t">Error</span>(<span class="s">"AUDIT_PSEUDONYM_SALT environment variable is required in production."</span>);
|
||
}
|
||
|
||
<span class="k">const</span> <span class="n">sinkList</span> = <span class="n">opts</span>.<span class="n">sinks</span> ?? [<span class="s">"payload"</span>, <span class="s">"stdout"</span>];
|
||
<span class="k">const</span> <span class="n">sinks</span>: <span class="t">IAuditLog</span>[] = [];
|
||
<span class="k">if</span> (<span class="n">sinkList</span>.<span class="n">includes</span>(<span class="s">"payload"</span>) && <span class="n">opts</span>.<span class="n">payloadConfig</span>) {
|
||
<span class="n">sinks</span>.<span class="n">push</span>(<span class="k">new</span> <span class="t">PayloadAuditLog</span>(<span class="n">opts</span>.<span class="n">payloadConfig</span>, <span class="n">getPayload</span>));
|
||
}
|
||
<span class="k">if</span> (<span class="n">sinkList</span>.<span class="n">includes</span>(<span class="s">"stdout"</span>)) {
|
||
<span class="n">sinks</span>.<span class="n">push</span>(<span class="k">new</span> <span class="t">StdoutJsonAuditLog</span>());
|
||
}
|
||
|
||
<span class="c">// 1. Pick fan-out vs single vs noop.</span>
|
||
<span class="k">const</span> <span class="n">inner</span>: <span class="t">IAuditLog</span> =
|
||
<span class="n">sinks</span>.<span class="n">length</span> > <span class="n">1</span> ? <span class="k">new</span> <span class="t">MultiSinkAuditLog</span>(<span class="n">sinks</span>)
|
||
: <span class="n">sinks</span>.<span class="n">length</span> === <span class="n">1</span> ? <span class="n">sinks</span>[<span class="n">0</span>]!
|
||
: <span class="k">new</span> <span class="t">NoopAuditLog</span>();
|
||
|
||
<span class="c">// 2. Wrap once with the OTel-bridge decorator — every sink sees correlationId.</span>
|
||
<span class="k">const</span> <span class="n">auditLog</span>: <span class="t">IAuditLog</span> = <span class="k">new</span> <span class="t">TraceIdEnrichingAuditLog</span>(<span class="n">inner</span>);
|
||
|
||
<span class="n">container</span>.<span class="n">bind</span><<span class="t">IAuditLog</span>>(<span class="n">AUDIT_SYMBOLS</span>.<span class="n">IAuditLog</span>).<span class="n">toConstantValue</span>(<span class="n">auditLog</span>);
|
||
<span class="k">return</span> { <span class="n">auditLog</span> };
|
||
}</pre>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 28px 0 0;"><strong style="color: var(--ink); font-weight: 500;">Why stderr for sink failures, not OTel?</strong> If one of the sinks <em>is</em> the OTel/Sentry exporter (or shares its infrastructure), routing the failure back through Sentry's <code>captureException</code> 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 <code>_type: "audit-sink-error"</code> right next to <code>_type: "audit"</code> in the same aggregator.</p>
|
||
</section>
|
||
|
||
<!-- ─── 05 ─ CORRELATION BRIDGE ───────────────────────────────────────── -->
|
||
<section id="bridge">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 05</div>
|
||
<div>
|
||
<h2 class="section-title">The <em>correlation</em> bridge.</h2>
|
||
<p class="section-blurb">A request enters; an OTel span opens; the use case calls <code>auditLog.record(...)</code>; <code>TraceIdEnrichingAuditLog</code> reads <code>currentTraceId()</code> from the active span context and stamps the entry's <code>correlationId</code>. The inner sink then writes durably. The same <code>traceId</code> 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.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="correlation-diagram">
|
||
<div class="correlation-row">
|
||
<div class="cstep">
|
||
<div class="ctag">step 01 · transport</div>
|
||
<strong>HTTP request enters</strong>
|
||
<div class="what">tRPC procedure receives input; controller resolves through DI</div>
|
||
</div>
|
||
<div class="cconnector">↓</div>
|
||
<div class="cstep span">
|
||
<div class="ctag">step 02 · instrumentation</div>
|
||
<strong>OTel span starts</strong>
|
||
<div class="what"><code>tracer.startSpan({ name: "blog.getArticle" })</code> · traceId = 0123abcd... · attaches to active context</div>
|
||
</div>
|
||
<div class="cconnector">↓</div>
|
||
<div class="cstep">
|
||
<div class="ctag">step 03 · use case</div>
|
||
<strong>getArticleUseCase(deps)(input)</strong>
|
||
<div class="what">repository read returns the article; then the audit call fires</div>
|
||
</div>
|
||
<div class="cconnector">↓</div>
|
||
<div class="cstep audit-call">
|
||
<div class="ctag">step 04 · audit</div>
|
||
<strong>auditLog.record({ ..., correlationId: undefined })</strong>
|
||
<div class="what">caller does NOT supply correlationId — explicit beats implicit, but absence yields to the decorator</div>
|
||
</div>
|
||
<div class="cconnector">↓</div>
|
||
<div class="cstep bridge">
|
||
<div class="ctag">step 05 · the bridge</div>
|
||
<strong>TraceIdEnrichingAuditLog.record(entry)</strong>
|
||
<div class="what">reads <code>currentTraceId()</code> = 0123abcd... · spreads into entry · forwards to inner sink</div>
|
||
</div>
|
||
<div class="cconnector">↓</div>
|
||
<div class="cstep">
|
||
<div class="ctag">step 06 · durability</div>
|
||
<strong>MultiSinkAuditLog → [Payload row, stdout JSON line]</strong>
|
||
<div class="what">both sinks see entry.correlationId = 0123abcd... · fan-out runs in parallel</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="fate-pair" style="margin-top: 24px;">
|
||
<div class="fate otel-fate">
|
||
<div class="fate-tag">OTel fate</div>
|
||
<strong>traceId 0123abcd</strong>
|
||
<div class="blurb">Span exported to Sentry via <code>SentrySpanProcessor</code>. Subject to head sampling (5–10% kept). Retained 30–90 days. PII-scrubbed at the processor layer. Engineering audience.</div>
|
||
</div>
|
||
<div class="fate audit-fate">
|
||
<div class="fate-tag">audit fate</div>
|
||
<strong>correlationId 0123abcd</strong>
|
||
<div class="blurb">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.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // the bridge in 7 lines — packages/core-audit/src/trace-id-enriching-audit-log.ts" style="margin-top: 32px;"><span class="k">export class</span> <span class="t">TraceIdEnrichingAuditLog</span> <span class="k">implements</span> <span class="t">IAuditLog</span> {
|
||
<span class="k">constructor</span>(<span class="k">readonly</span> <span class="n">inner</span>: <span class="t">IAuditLog</span>) {}
|
||
|
||
<span class="k">async</span> <span class="n">record</span>(<span class="n">entry</span>: <span class="t">AuditEntry</span>): <span class="t">Promise</span><<span class="t">void</span>> {
|
||
<span class="k">if</span> (<span class="n">entry</span>.<span class="n">correlationId</span>) <span class="k">return</span> <span class="k">this</span>.<span class="n">inner</span>.<span class="n">record</span>(<span class="n">entry</span>); <span class="c">// explicit wins</span>
|
||
<span class="k">const</span> <span class="n">traceId</span> = <span class="n">currentTraceId</span>();
|
||
<span class="k">if</span> (!<span class="n">traceId</span>) <span class="k">return</span> <span class="k">this</span>.<span class="n">inner</span>.<span class="n">record</span>(<span class="n">entry</span>); <span class="c">// no active span; pass through</span>
|
||
<span class="k">return</span> <span class="k">this</span>.<span class="n">inner</span>.<span class="n">record</span>({ ...<span class="n">entry</span>, <span class="h"><span class="n">correlationId</span>: <span class="n">traceId</span></span> }); <span class="c">// stamp + forward</span>
|
||
}
|
||
}</pre>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 28px 0 0;"><strong style="color: var(--ink); font-weight: 500;">The pivot.</strong> When the compliance team flags a suspicious VIEW on a regulated record, the audit entry's <code>correlationId</code> 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 <code>traceId</code> queries Loki for <code>{job="audit"} | json | correlationId="0123abcd"</code> and surfaces who, what, when, from where. One field, two channels, both worlds queryable.</p>
|
||
</section>
|
||
|
||
<!-- ─── 06 ─ GDPR ERASURE ─────────────────────────────────────────────── -->
|
||
<section id="erasure">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 06</div>
|
||
<div>
|
||
<h2 class="section-title">GDPR erasure — the <em>privileged path</em>.</h2>
|
||
<p class="section-blurb">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 <em>not</em> a normal API operation — append-only is the compliance backbone. The erasure path bypasses that backbone via <code>overrideAccess: true</code> on Payload. It is the only path that can; it lives behind an admin tRPC procedure and an optional Payload <code>afterDelete</code> hook. Two modes: <strong>pseudonymize</strong> (default — preserve the event, erase the identity) or <strong>delete</strong> (hard-remove every entry for an actor).</p>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // packages/core-audit/src/pseudonymize.ts"><span class="c">/**
|
||
* 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.
|
||
*/</span>
|
||
<span class="k">export function</span> <span class="n">pseudonymize</span>(<span class="n">actorId</span>: <span class="t">string</span>): <span class="t">string</span> {
|
||
<span class="k">const</span> <span class="n">salt</span> =
|
||
<span class="n">process</span>.<span class="n">env</span>[<span class="s">"AUDIT_PSEUDONYM_SALT"</span>] ?? <span class="s">"dev-fallback-salt-replace-in-prod"</span>;
|
||
<span class="k">const</span> <span class="n">hash</span> = <span class="n">createHash</span>(<span class="s">"sha256"</span>)
|
||
.<span class="n">update</span>(<span class="s">`${salt}:${actorId}`</span>)
|
||
.<span class="n">digest</span>(<span class="s">"hex"</span>);
|
||
<span class="k">return</span> <span class="s">`erased-${hash.slice(0, 16)}`</span>;
|
||
}</pre>
|
||
|
||
<pre class="code" data-lang="typescript // PayloadAuditLog.eraseSubject — overrideAccess bypasses the append-only rule" style="margin-top: 24px;"><span class="k">async</span> <span class="n">eraseSubject</span>(<span class="n">actorId</span>: <span class="t">string</span>, <span class="n">mode</span>: <span class="s">"pseudonymize"</span> | <span class="s">"delete"</span>): <span class="t">Promise</span><<span class="t">void</span>> {
|
||
<span class="k">const</span> <span class="n">payload</span> = <span class="k">await</span> <span class="n">getPayload</span>({ <span class="n">config</span>: <span class="k">this</span>.<span class="n">config</span> });
|
||
<span class="k">const</span> { <span class="n">docs</span> } = <span class="k">await</span> <span class="n">payload</span>.<span class="n">find</span>({
|
||
<span class="n">collection</span>: <span class="s">"audit-logs"</span>,
|
||
<span class="n">where</span>: { <span class="n">actorId</span>: { <span class="n">equals</span>: <span class="n">actorId</span> } },
|
||
<span class="h"><span class="n">overrideAccess</span>: <span class="k">true</span></span>, <span class="c">// ← THE only privileged path</span>
|
||
<span class="n">limit</span>: <span class="n">0</span>,
|
||
});
|
||
|
||
<span class="k">if</span> (<span class="n">mode</span> === <span class="s">"delete"</span>) {
|
||
<span class="k">for</span> (<span class="k">const</span> <span class="n">d</span> <span class="k">of</span> <span class="n">docs</span>) {
|
||
<span class="k">await</span> <span class="n">payload</span>.<span class="n">delete</span>({ <span class="n">collection</span>: <span class="s">"audit-logs"</span>, <span class="n">id</span>: <span class="n">d</span>.<span class="n">id</span>, <span class="h"><span class="n">overrideAccess</span>: <span class="k">true</span></span> });
|
||
}
|
||
<span class="k">return</span>;
|
||
}
|
||
|
||
<span class="c">// pseudonymize: preserve the event, replace the identity.</span>
|
||
<span class="k">const</span> <span class="n">token</span> = <span class="n">pseudonymize</span>(<span class="n">actorId</span>);
|
||
<span class="k">for</span> (<span class="k">const</span> <span class="n">d</span> <span class="k">of</span> <span class="n">docs</span>) {
|
||
<span class="k">await</span> <span class="n">payload</span>.<span class="n">update</span>({
|
||
<span class="n">collection</span>: <span class="s">"audit-logs"</span>,
|
||
<span class="n">id</span>: <span class="n">d</span>.<span class="n">id</span>,
|
||
<span class="n">data</span>: { <span class="n">actorId</span>: <span class="n">token</span> },
|
||
<span class="h"><span class="n">overrideAccess</span>: <span class="k">true</span></span>, <span class="c">// ← bypass update: () => false</span>
|
||
});
|
||
}
|
||
}</pre>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 28px 0;"><strong style="color: var(--ink); font-weight: 500;">Two ways to invoke it.</strong> First, an admin tRPC procedure at <code>audit.eraseSubject</code> — protected by your admin auth middleware; a DPO can invoke it from a request-handling UI or directly via the API. Second, the <code>createAuditErasureHook</code> Payload <code>afterDelete</code> hook on the users collection — when a user is deleted through normal Payload flow, the hook triggers <code>pseudonymize</code> automatically. Both reach the same <code>IAuditLog.eraseSubject</code> method; the hook is the ergonomic default for the "delete account" flow, the tRPC procedure is the explicit-request path.</p>
|
||
|
||
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 0;"><strong style="color: var(--ink); font-weight: 500;">What the stdout sink can't do.</strong> <code>StdoutJsonAuditLog.eraseSubject</code> writes a tombstone line (<code>_type: "audit-erasure"</code>) 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 <code>/loki/api/v1/delete</code> call with the <code>{actorId="user_123"}</code> label selector, run after the Payload pseudonymization completes. Out of scope for this package; documented in the guide.</p>
|
||
</section>
|
||
|
||
<!-- ─── 07 ─ WIRING PATH ──────────────────────────────────────────────── -->
|
||
<section id="wiring">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 07</div>
|
||
<div>
|
||
<h2 class="section-title">The <em>wiring</em> path.</h2>
|
||
<p class="section-blurb">After <code>pnpm turbo gen core-package audit</code> 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 <code>bindAll()</code>, same context-object passing convention. The <code>auditLog</code> field is added to <code>BindProductionContext</code> as optional (R51-style), so features that don't use it ignore it without breaking compile.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<pre class="code" data-lang="typescript // bindAll() — apps/web-next/src/server/bind-production.ts" style="margin-bottom: 32px;"><span class="k">export async function</span> <span class="n">bindAll</span>(): <span class="t">Promise</span><<span class="t">void</span>> {
|
||
<span class="c">// Rule 0 (independent of repo binding mode):</span>
|
||
<span class="k">const</span> { <span class="n">tracer</span>, <span class="n">logger</span> } = <span class="k">await</span> <span class="n">resolveInstrumentation</span>();
|
||
|
||
<span class="c">// Then the optional resolves (each gated on its package being scaffolded):</span>
|
||
<span class="k">const</span> { <span class="n">bus</span>, <span class="n">queue</span> } = <span class="k">await</span> <span class="n">resolveEventsAndJobsProduction</span>(<span class="n">config</span>);
|
||
<span class="k">const</span> { <span class="n">realtime</span>, <span class="n">realtimeRegistry</span> } = <span class="k">await</span> <span class="n">resolveRealtime</span>();
|
||
<span class="k">const</span> { <span class="h"><span class="n">auditLog</span></span> } = <span class="n">bindAudit</span>(<span class="n">sharedContainer</span>, { <span class="c">// ← NEW</span>
|
||
<span class="n">payloadConfig</span>: <span class="n">config</span>,
|
||
<span class="n">sinks</span>: [<span class="s">"payload"</span>, <span class="s">"stdout"</span>],
|
||
});
|
||
|
||
<span class="c">// Build one ctx object, pass to every feature binder:</span>
|
||
<span class="k">const</span> <span class="n">ctx</span>: <span class="t">BindProductionContext</span> = {
|
||
<span class="n">tracer</span>, <span class="n">logger</span>, <span class="n">config</span>, <span class="n">bus</span>, <span class="n">queue</span>, <span class="n">realtime</span>, <span class="n">realtimeRegistry</span>,
|
||
<span class="h"><span class="n">auditLog</span></span>, <span class="c">// ← NEW</span>
|
||
};
|
||
|
||
<span class="k">await</span> <span class="n">bindProductionAuth</span>(<span class="n">ctx</span>);
|
||
<span class="k">await</span> <span class="n">bindProductionBlog</span>(<span class="n">ctx</span>);
|
||
<span class="c">// ... every feature binder receives the same ctx.</span>
|
||
}</pre>
|
||
|
||
<div class="wiring-steps">
|
||
<div class="wiring-step">
|
||
<h4>Set <em>AUDIT_PSEUDONYM_SALT</em>.</h4>
|
||
<p><code>openssl rand -hex 32</code> into your secrets manager. Required in production; <code>bindAudit()</code> 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).</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Mount the <em>audit-logs</em> Payload collection.</h4>
|
||
<p>Add <code>auditLogsCollection</code> from <code>@repo/core-audit/collection</code> to the <code>collections</code> array in <code>packages/core-cms/src/payload.config.ts</code>. The collection enforces <code>update: () => false</code> and <code>delete: () => false</code> — the compliance backbone.</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Mount the admin tRPC router.</h4>
|
||
<p>In <code>packages/core-api/src/root.ts</code>, wire <code>createAuditRouter(auditLog)</code> from <code>@repo/core-audit/api</code>. Exposes <code>audit.eraseSubject</code> as an admin-only mutation; protect with your admin auth middleware.</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Call <em>bindAudit()</em> in app bootstrap.</h4>
|
||
<p>Inside the app's <code>bindAll()</code> (or its production sub-step), call <code>bindAudit(sharedContainer, { payloadConfig, sinks: ["payload", "stdout"] })</code> and add the returned <code>auditLog</code> to the <code>ctx</code> object passed to every feature binder.</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Install user-collection hooks (DPA recommended).</h4>
|
||
<p>In <code>packages/auth/src/di/bind-production.ts</code>, guarded by <code>if (ctx.auditLog)</code>, push <code>createAuditErasureHook</code> onto the users collection's <code>afterDelete</code> array and optionally <code>createAuditAfterReadHook</code> onto <code>afterRead</code>. Manual install (not auto-wired) because of the cross-package coupling.</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Set up a log shipper.</h4>
|
||
<p>Deploy Vector or Fluent Bit alongside your app container. Filter on <code>_type: "audit"</code>; forward to Grafana Loki / Elastic / Splunk. Sample configs are in the guide. The aggregator becomes the authoritative archive (independent of Payload's DB).</p>
|
||
</div>
|
||
<div class="wiring-step">
|
||
<h4>Verify.</h4>
|
||
<p><code>pnpm install && pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries</code>. Then trigger a VIEW event in dev mode and confirm an <code>_type: "audit"</code> JSON line appears on stdout within 100 ms. Run the hostile-actor immutability test from the guide before go-live.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── 08 ─ WHAT NOT TO LOG ──────────────────────────────────────────── -->
|
||
<section id="dpa">
|
||
<div class="section-head">
|
||
<div class="section-num">§ 08</div>
|
||
<div>
|
||
<h2 class="section-title">DPA — what <em>NOT</em> to log.</h2>
|
||
<p class="section-blurb">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: <em>why is this forbidden</em> and <em>what mechanism prevents it</em>. 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, <code>sendDefaultPii: false</code>).</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="forbidden-table">
|
||
<div class="forbidden-head">
|
||
<span>forbidden field</span>
|
||
<span>why</span>
|
||
<span>mechanism</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">payload / body</span>
|
||
<span class="why">Would mirror regulated content into a long-retention store. Audit logs become a secondary PII source — worse than the original because retention is longer.</span>
|
||
<span class="mech">Type shape — no field exists</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">oldValue / newValue</span>
|
||
<span class="why">Captures the before-or-after content of a regulated field. UPDATE actions track <code>changedFields</code> (NAMES) only — versioning lives elsewhere.</span>
|
||
<span class="mech">Type shape — no field exists</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">actor email / name</span>
|
||
<span class="why">Identifies the actor without pseudonymization. GDPR erasure could not honour a deletion request because we'd have shipped the name into Loki / Elastic.</span>
|
||
<span class="mech">Convention — <code>actorId</code> is doc id only; R36</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">raw IP address</span>
|
||
<span class="why">Full IPv4 / IPv6 is itself personal data in EU jurisdictions. Truncation reduces granularity to a network segment, sufficient for incident response.</span>
|
||
<span class="mech">Helper — <code>truncateIp</code> throws on malformed</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">arbitrary action strings</span>
|
||
<span class="why">Free-string actions defeat compliance sampling — auditors can't enumerate the action enum. New actions require explicit type bumps + reviewer attention.</span>
|
||
<span class="mech">Closed enum <code>AuditAction</code></span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">session tokens / API keys</span>
|
||
<span class="why">Capturing the credential the actor used would compound the leak if the audit store is itself breached. Identifiers go in <code>requestId</code>, not the credential.</span>
|
||
<span class="mech">Convention — only <code>actorId</code> + <code>requestId</code></span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">implicit tenant</span>
|
||
<span class="why">Multi-tenancy must be expressed even in single-tenant projects (with the <code>"default"</code> sentinel) so that any later split is mechanical, not archaeological.</span>
|
||
<span class="mech">Type — <code>scope.tenant</code> is required</span>
|
||
</div>
|
||
<div class="forbidden-row">
|
||
<span class="field">defaultPII via SDK</span>
|
||
<span class="why">Sentry's <code>sendDefaultPii: true</code> would ship request bodies, headers, and user identifiers automatically — the opposite of the audit posture.</span>
|
||
<span class="mech">CI grep gate · R31</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="posture-card">
|
||
<h3>Compliance posture by <em>construction</em>.</h3>
|
||
<p>Most fields you'd be tempted to add to an audit entry don't exist on <code>AuditEntry</code>. That's not an oversight — it's the posture. Reviewers reading PRs catch only the mistakes that compile. <code>payload</code>, <code>body</code>, <code>oldValue</code>, <code>newValue</code>: these never compile, so they never ship. <code>truncateIp</code> throws on malformed input rather than silently scrubbing — compliance regimes prefer hard failures.</p>
|
||
<p>The <strong>append-only</strong> guarantee is enforced at three layers in defense-in-depth: Payload's <code>update: () => false</code> access rule on the collection (in-app); the absence of an UPDATE method on the <code>IAuditLog</code> protocol (the only erasure verb is the privileged <code>eraseSubject</code>); 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 <code>audit_logs</code> table, the shipped lines in Loki / Elasticsearch remain as the authoritative record.</p>
|
||
<p>The <strong>correlationId</strong> 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. <strong>Two channels, one bridge.</strong> That's the whole design.</p>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<footer class="frame">
|
||
<div class="foot-grid">
|
||
<div>
|
||
<h5>Sibling explainer</h5>
|
||
<a href="data-flow-explainer.html">data-flow-explainer.html</a>
|
||
<a href="di-explainer.html">di-explainer.html</a>
|
||
</div>
|
||
<div>
|
||
<h5>Decisions</h5>
|
||
<a href="../decisions/adr-018-audit-and-compliance.md">adr-018 · audit & compliance</a>
|
||
<a href="../decisions/adr-014-instrumentation-sentry.md">adr-014 · instrumentation</a>
|
||
<a href="../decisions/adr-017-opentelemetry-migration.md">adr-017 · OTel migration</a>
|
||
<a href="../decisions/adr-015-events-and-jobs.md">adr-015 · events & jobs</a>
|
||
</div>
|
||
<div>
|
||
<h5>Code</h5>
|
||
<a href="../../packages/core-shared/src/audit/audit-entry.ts">audit-entry.ts</a>
|
||
<a href="../../packages/core-shared/src/audit/truncate-ip.ts">truncate-ip.ts</a>
|
||
<a href="../../packages/core-audit/src/trace-id-enriching-audit-log.ts">trace-id-enriching-audit-log.ts</a>
|
||
<a href="../../packages/core-audit/src/multi-sink-audit-log.ts">multi-sink-audit-log.ts</a>
|
||
<a href="../../packages/core-audit/src/di/bind-audit.ts">bind-audit.ts</a>
|
||
<a href="../../packages/core-audit/src/hooks/audit-after-read-hook.ts">audit-after-read-hook.ts</a>
|
||
<a href="../../packages/core-audit/src/hooks/audit-erasure-hook.ts">audit-erasure-hook.ts</a>
|
||
</div>
|
||
<div>
|
||
<h5>Specs</h5>
|
||
<a href="vertical-feature-spec.md">vertical-feature-spec.md</a>
|
||
<a href="overview.md">overview.md</a>
|
||
<a href="dependency-flow.md">dependency-flow.md</a>
|
||
<a href="../decisions/adr-018-audit-and-compliance.md">ADR-018 · audit & compliance</a>
|
||
</div>
|
||
<div>
|
||
<h5>Guide</h5>
|
||
<a href="../guides/audit-and-compliance.md">audit-and-compliance.md</a>
|
||
<a href="../guides/events-and-jobs.md">events-and-jobs.md</a>
|
||
<a href="../guides/realtime.md">realtime.md</a>
|
||
</div>
|
||
</div>
|
||
<div class="colophon">
|
||
Set in Fraunces & JetBrains Mono. Sister page to <code>data-flow-explainer.html</code> and <code>di-explainer.html</code>. Single file, no build step. Companion to ADR-018.
|
||
</div>
|
||
</footer>
|
||
|
||
<script>
|
||
// Smooth-scroll anchors
|
||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||
a.addEventListener('click', e => {
|
||
const target = document.querySelector(a.getAttribute('href'));
|
||
if (target) {
|
||
e.preventDefault();
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|