Files
agentic-dev/docs/decisions/adr-025-eu-compliance-baseline.md
Danijel Martinek 52a1c5fa3a docs: introduce EU compliance baseline strategy (ADR-025)
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.
2026-05-18 19:30:53 +02:00

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:

  1. Already covered by prior ADRs:

    • PII boundary on observability (ADR-017 §7 — sendDefaultPii: false CI gate, server-side scrubbing, replay masking)
    • Audit logging baseline (ADR-018 — @repo/core-audit, DPA-aligned schema, eraseSubject pseudonymization)
    • 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)
  2. 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).

  3. 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/jobs reads 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.

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 IDataExport with 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: ConsentChecked attached by withConsent wrapper at bind time
  • Manifest field: requiresConsent: ["analytics"] per use case
  • ESLint rule: no-undeclared-consent-check cross-checks consent.requires("X") literal calls
  • Boot assertion: assertFeatureConformance requires ConsentChecked brand when requiresConsent.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/security plus per-framework re-export.

  • Rate-limit primitive — fourth conformance channel after audit/analytics/consent. IRateLimit interface in core-shared/rate-limit:

    interface IRateLimit {
      consume(
        key: string,
        weight?: number,
      ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }>;
      reset(key: string): Promise<void>;
    }
    
    • Brand: RateLimited attached by withRateLimit wrapper
    • Manifest field: rateLimit: { window: "1m", budget: 60 } per use case (defaults; runtime overrides via ctx.rateLimit config)
    • ESLint rule: no-undeclared-rate-limit warns when a use case in an auth/write/export category lacks a rateLimit declaration
    • Boot assertion: assertFeatureConformance requires RateLimited brand when rateLimit is set
    • Consumer wires Redis/Upstash impl; NoopRateLimit always-allows for tests + dev

    Rate-limit budgets at the manifest level are defaults — overridable at runtime via ctx.rateLimit config (mirrors how analytics backend is consumer-wired). Manifest declaration is for the binding gate; runtime values are deployment-environment-specific.

  • SBOM in CIcyclonedx-npm step in ci.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 reference
  • retention-policy.example.yml — generator-output reference
  • sub-processors.example.yml — generator-output reference
  • incident-runbook.template.md (fill-in)
  • dsr-procedure.template.md
  • backup-policy.template.md
  • password-policy.template.md
  • device-policy.template.md
  • onboarding.template.md
  • offboarding.template.md
  • README.md (explains the docs/compliance/ vs compliance/ 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/IDataDelete walk Epic A's PII tags at runtime → A must finish before B story 1
  • D's data-map.example.yml documents Epic A's PII schema → A's design must be settled before D
  • D's dsr-procedure.template.md references 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-checkconsent.requires(...) literal calls must match manifest's requiresConsent
  • no-undeclared-rate-limit — auth/write/export categorized use cases without rateLimit field
  • pii-declaration-must-be-complete — Payload pii: true fields 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.

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-level custom.* 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 is pnpm 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 custom config gains two extensions. Doc burden in glossary + conformance-quickref.md increases 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 alongside docs/, packages/, apps/. Consumers will see it; it's intentional but it's a new convention.
  • DSR cascade is non-trivial. IDataDelete walking 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.
  • ADR-006 — vertical feature packages (boundary tags new optional cores fit within)
  • ADR-017 — OTel + observability PII boundary (the boundary IConsent does NOT cross — observability stays id-only)
  • ADR-018 — audit + compliance (the sibling channel core-dsr complements 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; IConsent from Epic B will gate IAnalytics.track calls in consumer products)