ADR-025 plans 4 epics that raise the template's DPA/GDPR coverage from ~50% (ADR-017/018/022/023/024) to ~80%. Adds two optional cores (core-dsr, core-consent), a fourth conformance channel (rate-limit), three new manifest fields, three new generators with CI drift gates, and a docs/compliance/ + compliance/ split for templates vs evidence. Documents the audit ↔ DSR sibling-not-overlap distinction (audit records access; DSR acts on the underlying data). Four explicit deferrals with revisit triggers (RBAC, MFA, breach detection, Art. 22). Glossary updated with DSR, Consent, Rate-limit, PII inventory, Retention policy, Sub-processor, docs/compliance/ vs compliance/ entries.
24 KiB
ADR-025 — EU compliance baseline (DPA/GDPR template scope)
Status: Accepted Date: 2026-05-18 Related: ADR-006 (vertical feature packages), ADR-017 (OTel + observability PII boundary), ADR-018 (audit + compliance), ADR-022 (library evaluation policy — extended here for sub-processors), ADR-023 (CI security + supply chain — SBOM amended here), ADR-024 (product analytics channel) Companion PRDs (one per epic, sequenced):
docs/work/prds/compliance-manifests-pii-retention-subprocessors.prd.md(Epic A)docs/work/prds/dsr-consent-and-cookie-banner.prd.md(Epic B)docs/work/prds/security-headers-rate-limit-sbom.prd.md(Epic C)docs/work/prds/compliance-docs-scaffolds.prd.md(Epic D)
Context
A DPA/GDPR compliance playbook (22 sections, stack-agnostic) was reviewed against the template's current state on 2026-05-18. The audit produced a clean three-way split:
-
Already covered by prior ADRs:
- PII boundary on observability (ADR-017 §7 —
sendDefaultPii: falseCI gate, server-side scrubbing, replay masking) - Audit logging baseline (ADR-018 —
@repo/core-audit, DPA-aligned schema,eraseSubjectpseudonymization) - EU library residency (ADR-022 — hard filter in
/evaluate-library) - Supply-chain + CI security (ADR-023 — Renovate SHA pinning, Socket.dev, audit signatures, CodeQL, gitleaks, trace revalidation)
- Analytics PII deferral (ADR-024 — explicit consumer-policy boundary)
- PII boundary on observability (ADR-017 §7 —
-
Template-shaped gap — 10 items the playbook flagged that the template can codify before any consumer adopts it. These are conformance-pattern shaped (manifest fields, brands, ESLint rules, generators) or scaffolding (interfaces, default middleware, fill-in docs).
-
Product-shaped (deferred) — 3 items that need product shape before being meaningful. Each has a documented trigger condition for revisit.
The motivating pressure: a consumer adopting this template today gets ~50% of the playbook's surface for free. The 10-item template-shaped gap is what this ADR plans (raising coverage to ~80%); the remaining ~20% product/process/legal scope stays consumer-owned and is documented as such.
Audit + DSR is the canonical confusion to flag upfront: audit records personal-data access (immutable journal); DSR acts on the underlying data in response to user requests (mutator/exporter). They are sibling concerns, not duplicates.
Decision
Ship the 10 template-shaped items across four epics, sequenced A → B → D with C interleaved opportunistically. Defer 3 items explicitly. Add 3 new manifest fields, 2 new optional cores, 3 new ESLint rules.
The 4 epics
Epic A — Declarative compliance manifests
Items: PII inventory + data retention + sub-processor inventory.
Three declarative artifacts, each driven by a different surface and a generator that emits the audit-evidence YAML:
| Artifact | Declaration site | Generator output |
|---|---|---|
| PII inventory | custom.pii: { category, purpose, retention, exportable, restrictable } per Payload field |
compliance/data-map.yml |
| Retention policy | custom.retention: { activeRetention, postDeletion, purgeSchedule, hardDeleteAfter } per Payload collection |
compliance/retention-policy.yml |
| Sub-processor inventory | Extended ADR-022 library traces — frontmatter fields is-sub-processor, processes-pii, data-sent, region, dpa-signed, sccs-required, contact |
compliance/sub-processors.yml |
Key design decisions:
-
PII at the field level, not the manifest level. PII is a storage question ("what personal data does this system hold?"), not an action question. Existing manifest fields (
audits,publishes,analyticsEvents) are all event-shaped — emissions from use cases. PII fields don't fit that shape. Tagging at the field level puts the metadata where DSR consumes it at runtime. -
Retention at the collection level, not per use case. Same rationale — retention is a storage property. Per-field PII retention overrides apply where the PII tag specifies a stricter retention than the collection default (more-specific wins). Background purge job in
core-shared/jobsreads collection config at boot. -
Sub-processors via ADR-022 traces, not standalone file. Every direct-dep library trace already records EU residency, license, CVE acceptance. Extending it with the sub-processor fields (DPA signed date, SCCs, contact, PII processed, data sent, region) unifies two related obligations in one record. Pure-HTTP sub-processors (REST calls without an SDK) get hand-authored entries with no backing trace as an exception, CI-flagged.
The three generators run via pnpm compliance:emit-all. CI gate verifies generator output matches the source declarations (drift detection).
Background purge job: core-shared/jobs/retention-purge.job.ts. Reads custom.retention from each collection at boot; schedules per-collection purge cadence; emits an IAuditLog.record({ action: "DELETE", reason: "retention-policy" }) audit entry per row purged.
Epic B — DSR + consent + cookie banner
Items: DSR scaffold + consent abstraction + cookie consent UI.
Builds the user-rights surface end-to-end:
@repo/core-dsr — new optional core. Four interfaces:
interface IDataExport {
exportSubjectData(
subjectId: string,
format: "json" | "json-ld",
): Promise<UserDataBundle>;
}
interface IDataDelete {
deleteSubjectData(
subjectId: string,
mode: "soft" | "cascade-hard",
): Promise<DeletionCertificate>;
}
interface IDataRectify {
updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void>;
}
interface IProcessingRestriction {
setRestriction(subjectId: string, granted: boolean): Promise<void>;
isRestricted(subjectId: string): Promise<boolean>;
}
DSR ops walk Payload collections at runtime, using the field-level custom.pii tags from Epic A. IDataExport walks fields tagged exportable: true; IDataDelete walks all PII fields and cascades; IProcessingRestriction writes a flag on the user record that every read path checks.
Scope cuts on DSR:
- Art. 20 (portability) folded into Art. 15 (access) — same
IDataExportwith format option - Art. 21 (objection) → consent epic via
IConsent.withdraw - Art. 22 (automated decision-making) → deferred (no ML in template; future ADR when a consumer adds automated decisions)
DSR ops are themselves PII access events — every IDataExport/IDataDelete/IDataRectify call writes an audit entry. After IDataDelete, core-audit.IAuditLog.eraseSubject(actorId, "pseudonymize") scrubs the audit trail (preserves the events, removes the identifier).
@repo/core-consent — new optional core. Sibling channel parallel to audit/analytics:
interface IConsent {
isGranted(subjectId: string, category: ConsentCategory): Promise<boolean>;
grant(
subjectId: string,
categories: ConsentCategory[],
record: ConsentRecord,
): Promise<void>;
withdraw(subjectId: string, categories: ConsentCategory[]): Promise<void>;
getCategories(subjectId: string): Promise<ConsentCategory[]>;
}
ConsentCategory is a consumer-typed string-literal-union (default: "essential" | "functional" | "analytics" | "marketing", extensible). Conformance treatment:
- Brand:
ConsentCheckedattached bywithConsentwrapper at bind time - Manifest field:
requiresConsent: ["analytics"]per use case - ESLint rule:
no-undeclared-consent-checkcross-checksconsent.requires("X")literal calls - Boot assertion:
assertFeatureConformancerequiresConsentCheckedbrand whenrequiresConsent.length > 0
Consent grant/withdraw events are themselves audited (auditLog.record({ action: "CONSENT_GRANT", category: "marketing" })).
<CookieConsentBanner> in @repo/core-ui — atomic component. Default visual treatment baked for EU prominence requirements (Reject + Accept side-by-side, same size, equal prominence per EU regulator guidance). Granular (essential/functional/analytics/marketing). Consumer wires IConsent via React context. Storybook story doubles as human reading-room for compliant UX.
Endpoint scaffolds: /api/gdpr/{export,delete,rectify,restrict} — consumer-wireable routes. Live in apps/web-next/app/api/gdpr/ and apps/web-tanstack/src/routes/api/gdpr/.
Epic C — Security hardening
Items: Security headers middleware + rate-limit primitive + SBOM in CI.
Small individual scope; grouped for dispatch efficiency. Each item is independent.
-
Security headers middleware — default Next.js + TanStack middleware shipping HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. CSP customizable per consumer (separate config; default is restrictive). Lives in
core-shared/securityplus per-framework re-export. -
Rate-limit primitive — fourth conformance channel after audit/analytics/consent.
IRateLimitinterface incore-shared/rate-limit:interface IRateLimit { consume( key: string, weight?: number, ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }>; reset(key: string): Promise<void>; }- Brand:
RateLimitedattached bywithRateLimitwrapper - Manifest field:
rateLimit: { window: "1m", budget: 60 }per use case (defaults; runtime overrides viactx.rateLimitconfig) - ESLint rule:
no-undeclared-rate-limitwarns when a use case in an auth/write/export category lacks arateLimitdeclaration - Boot assertion:
assertFeatureConformancerequiresRateLimitedbrand whenrateLimitis set - Consumer wires Redis/Upstash impl;
NoopRateLimitalways-allows for tests + dev
Rate-limit budgets at the manifest level are defaults — overridable at runtime via
ctx.rateLimitconfig (mirrors how analytics backend is consumer-wired). Manifest declaration is for the binding gate; runtime values are deployment-environment-specific. - Brand:
-
SBOM in CI —
cyclonedx-npmstep inci.yml, artifact uploaded per release. Amendment to ADR-023.
Epic D — Compliance docs scaffolds
Items: Fill-in templates for runbooks, policies, and the pre-launch checklist.
Pure docs work. Lands last so it references the manifest fields, interfaces, and middleware shipped by A/B/C.
Template-shipped (under docs/compliance/):
data-map.example.yml— generator-output referenceretention-policy.example.yml— generator-output referencesub-processors.example.yml— generator-output referenceincident-runbook.template.md(fill-in)dsr-procedure.template.mdbackup-policy.template.mdpassword-policy.template.mddevice-policy.template.mdonboarding.template.mdoffboarding.template.mdREADME.md(explains thedocs/compliance/vscompliance/split)
Consumer-created (under compliance/ at repo root):
data-map.yml(generator output)retention-policy.yml(generator output)sub-processors.yml(generator output from extended ADR-022 traces)*.md(filled-in copies of templates)
Plus: docs/guides/pre-launch-compliance-checklist.md — playbook §19 verbatim with template-specific wiring noted.
Sequencing
Order: A → B → D, with C interleaved opportunistically.
Hard dependencies:
- B's
IDataExport/IDataDeletewalk Epic A's PII tags at runtime → A must finish before B story 1 - D's
data-map.example.ymldocuments Epic A's PII schema → A's design must be settled before D - D's
dsr-procedure.template.mdreferences Epic B's endpoints → B should be design-settled before D's PRD is decomposed
C is dependency-free; sandcastle picks C stories during gaps in A/B/D.
Deferrals (explicit, with revisit triggers)
| Deferred | Why deferred | Trigger to revisit |
|---|---|---|
| RBAC primitive (roles + permissions + tenant scoping) | Needs product-side decisions: which roles exist, multi-tenant or not, permission granularity | First downstream consumer ships with a stable role model |
MFA + password policy + lockout (auth feature extension) |
Needs identity-infrastructure choices (TOTP/WebAuthn), threat-model-specific policy values, OTP delivery vendor (ADR-022 territory) | First downstream consumer establishes auth threat model |
| Breach detection patterns (failed-login burst, bulk-access anomaly, off-hours admin) | Needs real auth flows, analytics backend, on-call infrastructure, product-specific anomaly definitions | First downstream consumer has live traffic + observability backend |
| GDPR Art. 22 (automated decision-making) — sub-deferral within Epic B | Template has no ML/automated decisions | First downstream consumer adds automated decisions |
Each deferral has a documented trigger so the decision-when can be answered by the consumer, not the template authors.
Consumer-scope items (explicitly out of template)
These appear in the playbook but are NOT template-shaped:
- Infrastructure (§1, §12) — EU region pinning of compute/storage/backups, TLS at deploy edge, encryption-at-rest config, VPN/bastion network boundaries, backup strategy + restore testing
- Legal (§17) — DPA, Privacy Policy, ToS, SCCs for non-EU sub-processors, DPIA artifacts, RoPA documents
- Organizational (§14, §15) — MDM enrollment, HR onboarding/offboarding scripts (the script is template; the policy is consumer), NDAs, security training, background checks, quarterly access reviews, pentest scheduling
Epic D ships fill-in templates for some documentation artifacts above; the values stay consumer-filled.
Manifest schema impact
Per-use-case fields grow from 5 to 7:
- Existing:
mutates,audits,publishes,consumes,analyticsEvents - Added by ADR-025:
requiresConsent,rateLimit
Per-Payload-collection custom config grows:
- Added by ADR-025:
pii(per field),retention(per collection)
Per-library-trace frontmatter grows (extends ADR-022):
- Added by ADR-025:
is-sub-processor,processes-pii,data-sent,region,dpa-signed,sccs-required,contact
Conformance ESLint rule impact
Rule count: 7 → 10. New rules at warn severity (matching the audit/analytics-event convention):
no-undeclared-consent-check—consent.requires(...)literal calls must match manifest'srequiresConsentno-undeclared-rate-limit— auth/write/export categorized use cases withoutrateLimitfieldpii-declaration-must-be-complete— Payloadpii: truefields missing required sub-keys (category, purpose, retention)
Generator + CI gate inventory
| Generator | Source | Output | CI gate |
|---|---|---|---|
pnpm compliance:data-map |
Payload field custom.pii |
compliance/data-map.yml |
output matches collections (drift detection) |
pnpm compliance:retention-policy |
Payload collection custom.retention |
compliance/retention-policy.yml |
output matches collections |
pnpm compliance:sub-processors |
docs/library-decisions/*.md with is-sub-processor: true |
compliance/sub-processors.yml |
output matches traces |
pnpm compliance:emit-all |
runs all three | three files | runs all three CI gates |
Alternatives considered
A. One mega-epic covering all 10 items
Single PRD, single epic, ~30-40 stories. Pros: tight coupling across the manifest-schema changes; one review surface. Cons: enormous PR backlog with no natural checkpoint; reviewer fatigue; if half-shipped, the partial state leaves an ambiguous compliance surface.
Rejected. The four-epic split keeps each PRD focused enough for a useful single-document review.
B. Per-item ADRs (10 ADRs)
One ADR per item — finer granularity. Pros: each architectural decision recorded in isolation. Cons: 10 ADRs to maintain, much repetition (each restates the playbook context), no unifying strategy doc, harder to answer cross-cutting questions like "why these 3 deferrals" without re-reading 4+ ADRs.
Rejected. ADR-025 is the unifying strategy; per-epic PRDs handle implementation specifics. Future deepening decisions on individual items can spawn their own ADRs as needed (e.g., when DSR cascade semantics need a specific architectural call, that becomes ADR-NNN).
C. Wait until the first downstream consumer asks
Don't build any of this until a consumer arrives with a real compliance requirement.
Rejected. The whole value proposition of this template is that DPA/GDPR-shaped consumers don't have to invent these surfaces. The "named-consumer-now" rule from ADR-022 applies to library adoption, not to template surface — the consumer of this surface is "every downstream EU-bound product," which is real and immediate.
D. PII declared at use-case manifest level instead of Payload field level
pii: [{ category, purpose, retention }] per use case, mirroring audit/publishes.
Rejected. Duplicates metadata across every use case touching the same field. Wrong semantic layer — PII is a storage question, not an action question. DSR (Epic B) needs runtime access to PII tags at the field level to walk Payload collections; manifest-level tags would require synthesis at runtime.
E. DSR split across multiple cores (@repo/core-data-export, @repo/core-data-delete, etc.)
Maximum granularity. Pros: consumers adopt only what they need. Cons: 4+ packages to scaffold, tight coupling in practice (delete cascade needs to know export's PII tags), overkill — each "core" would have one interface.
Rejected. One @repo/core-dsr with 4 interfaces, mirroring core-shared's tracer/logger/metrics packaging pattern (multiple interfaces in one package). Opt-in is at the package level, not the interface level.
F. Consent folded into core-audit or core-analytics
IConsent added to an existing core.
Rejected. Audit records, consent gates — different abstractions. Conflates package purpose. Analytics is only one of many consent-gated channels (marketing, profiling, cookies, third parties); putting consent inside analytics is too narrow.
G. Rate-limit as interface-only (no brand)
Just an IRateLimit contract. Consumers call rateLimit.consume(...) where they need.
Rejected. Rate-limit drift would only surface when traffic hits — way too late. The five-latency drift detection is the template's signature pattern. Skipping it for rate-limit when it's universally applicable to auth/write/export endpoints (per playbook §5) leaves a real hole. Manifest declaration at the binding gate + runtime budget override gives both static enforcement and deployment flexibility.
H. Compliance docs all under docs/compliance/ (no root compliance/ directory)
Templates and live artifacts co-located.
Rejected. The template ships only the shape; the consumer fills in the evidence. Auditors expect compliance/ at the repo root (matches playbook §16 + standard SOC 2/ISO 27001 audit prep). Splitting locations matches the template-vs-consumer mental model used throughout this session.
Consequences
Positive
- Compliance surface ~80% template-shipped. From ~50% pre-ADR (audit + PII boundary + library residency + supply chain) to ~80% post-epics. Remaining 20% is consumer-scope by design.
- Conformance pattern extended consistently. Two new manifest fields (
requiresConsent,rateLimit) plus two new collection-levelcustom.*extensions all follow the established pattern. Three new ESLint rules at warn level. Three new manifest-driven generators. New consumers learn the pattern once. - DPA audit posture improves materially. Sub-processor inventory, retention policy, data map, DSR endpoints, cookie consent are concrete artifacts a regulator or customer audit expects.
- Explicit deferrals prevent premature design. RBAC, MFA, breach detection, Art. 22 won't be re-suggested by future agents — ADR-025 records the trigger conditions.
- Two new optional cores (
core-dsr,core-consent) match the established pattern. Template-tiers grows by two; scaffold path ispnpm turbo gen core-package <name>. - Rate-limit gets first-class treatment. Fourth conformance channel; auth/write/export endpoints can't ship without a declared budget.
- The audit ↔ DSR distinction is documented. Future agents won't conflate the two — glossary entries plus this ADR's "Context" section make the split explicit.
Negative
- Manifest schema grows substantially. Per-use-case fields go from 5 to 7. Per-collection
customconfig gains two extensions. Doc burden in glossary +conformance-quickref.mdincreases proportionally. - Conformance ESLint rule count: 7 → 10. CLAUDE.md and quickref need rule-table updates each epic.
- Two new optional cores to maintain. Each needs versioning + CHANGELOG (per ADR-021).
compliance/directory becomes a new root location. Adds a top-level directory alongsidedocs/,packages/,apps/. Consumers will see it; it's intentional but it's a new convention.- DSR cascade is non-trivial.
IDataDeletewalking every Payload collection's PII fields requires Epic A's PII tags to be complete and correct. Epic B will surface gaps in Epic A's coverage during integration. - ADR-022 amendment. Adding sub-processor fields to library traces is an extension to ADR-022's frontmatter spec. Existing traces need backfill (the weekly revalidation cron from ADR-023 will surface incomplete traces).
- ADR-023 amendment. SBOM generation step adds one workflow line; minor but counted.
Neutral
- No new CI gates beyond what generators introduce. The three drift-detection gates (data-map, retention-policy, sub-processors) all reuse the existing CI workflow shape.
- No vendor lock-in. All interfaces (DSR cascade target, consent backend, rate-limit backend, security-header CSP values) remain consumer-decisions. Template ships interfaces + Noop/reference impls only.
- Deferred items remain deferred. ADR-025 doesn't preclude building RBAC/MFA/breach-detection/Art. 22 later — it just establishes that those decisions wait for product shape.
- Cookie banner ships with opinionated EU-prominence defaults. Consumers can override visual treatment but the default is the legally-defensible shape.
Related
- ADR-006 — vertical feature packages (boundary tags new optional cores fit within)
- ADR-017 — OTel + observability PII boundary (the boundary
IConsentdoes NOT cross — observability stays id-only) - ADR-018 — audit + compliance (the sibling channel
core-dsrcomplements without overlapping) - ADR-022 — library evaluation policy (extended here with sub-processor frontmatter fields)
- ADR-023 — CI security + supply chain (SBOM amended here; rate-limit complements the supply-chain stack)
- ADR-024 — product analytics channel (sibling capture channel;
IConsentfrom Epic B will gateIAnalytics.trackcalls in consumer products)