docs: seed PRD for DSR + consent + cookie banner epic (ADR-025 Epic B)

Implementation seed for ADR-025 Epic B: two new optional cores
(@repo/core-dsr with 4 interfaces, @repo/core-consent with IConsent +
ConsentChecked brand + requiresConsent manifest field), CookieConsentBanner
in core-ui with EU-prominence defaults, subject-linkage types in
core-shared, ADR-018 amendment adding 4 new audit action types
(CONSENT_GRANT/WITHDRAW + RESTRICT/UNRESTRICT). 15 user stories ordered
by the in-epic sequencing hints. Status: approved — ready for
pnpm work decompose.
This commit is contained in:
2026-05-19 11:41:25 +02:00
parent 07caaa1695
commit ae886a4499
2 changed files with 489 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
{
"updated_at": "2026-05-18T20:23:30.752Z",
"updated_at": "2026-05-19T09:41:27.367Z",
"epics": {
"binder-wrap-helper": {
"status": "done",

View File

@@ -0,0 +1,488 @@
---
id: dsr-consent-and-cookie-banner
title: DSR + consent abstraction + cookie consent banner — Epic B of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-19T09:36:42Z
updated: 2026-05-19T09:41:27.097Z
---
## Problem
Epic A (compliance manifests) shipped declarative PII inventory + retention + sub-processors. A consumer can now answer "what personal data does this system hold?" via `compliance/data-map.yml`. They cannot yet answer "how does a user exercise their GDPR rights against that data?" or "did this user consent to marketing emails?" or "where's the cookie banner that asks?"
Concretely, three load-bearing surfaces remain missing:
- **Data Subject Rights (DSR) endpoints** — every EU-bound consumer needs `/api/gdpr/{export,delete,rectify,restrict}` to satisfy GDPR Arts. 15, 16, 17, 18, 20. Without them, the consumer either reinvents the cascade walk over their Payload collections (high failure rate — easy to miss a collection or fail to redact one subject in a multi-subject row) or admin-mediates every request manually (slow, doesn't scale, audit-fragile).
- **Consent abstraction** — analytics gating (`analytics.track` from ADR-024) currently has no consent check. Every consumer who ships analytics ends up adding a homegrown consent flag, with no audit trail proving consent was given. Art. 7 requires demonstrable consent; today's template can't demonstrate it.
- **Cookie consent UI** — no template surface for first-visit consent collection. Every consumer reinvents the banner, and getting Reject All / Accept All equally prominent (CNIL guidance, EDPB Art. 7 interpretation) is non-obvious. Compliance failures here are the most visible regulator-facing surface.
ADR-025 settled the strategy: two new optional cores (`@repo/core-dsr`, `@repo/core-consent`) + cookie banner in `core-ui`. This PRD is the implementation seed for Epic B.
## Goal
Ship the user-rights surface end-to-end so a downstream consumer can: (1) expose DSR endpoints that walk Epic A's PII tags to export/delete/rectify/restrict any subject's data, (2) declare per-use-case consent requirements that gate analytics + marketing emission with audit-logged proof, (3) drop in a compliant cookie consent banner with EU-prominence defaults. Epic A's PII + retention machinery powers DSR cascade; Epic B's consent gates close the analytics PII loop.
## In scope
### `@repo/core-dsr` (new optional core)
- Scaffolded via `pnpm turbo gen core-package dsr`
- Four interfaces:
- `IDataExport.exportSubjectData(subjectId, format: "json" | "json-ld"): Promise<UserDataBundle>` — covers Arts. 15 + 20
- `IDataDelete.deleteSubjectData(subjectId, mode: "soft" | "cascade-hard"): Promise<DeletionCertificate>` — Art. 17
- `IDataRectify.updateSubjectField(subjectId, collection, field, value): Promise<void>` — Art. 16
- `IProcessingRestriction.{setRestriction, isRestricted}` — Art. 18
- Payload-backed reference impls walking Epic A's `custom.pii` tags and the new collection-level `custom.subject` linkage
- Protocol-agnostic handlers in `core-dsr/handlers/` returning normalized `{ status, body, headers }`
- tRPC router `core-dsr/dsr.router.ts` consumed by `core-api`'s appRouter
- Subject-linkage TypeScript types in `core-shared/payload/`: `SubjectLink`, `SubjectLinkRole`, `CollectionSubject`
- Ambient declaration extending Payload's `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
- Default schema.org JSON-LD `@context` shipped at `core-dsr/contexts/user-data.jsonld`; consumer-overridable
- `DeletionCertificate` type derived from audit entry; returned to caller, not separately persisted
### `@repo/core-consent` (new optional core)
- Scaffolded via `pnpm turbo gen core-package consent`
- Interface:
- `IConsent.{isGranted, grant, withdraw, getCategories}` per the ADR-025 shape
- `ConsentCategory` string-literal union with escape hatch (matches `PiiCategory` pattern): `"essential" | "functional" | "analytics" | "marketing" | (string & Record<never, never>)`
- `withConsent` wrapper attaching `ConsentChecked` brand at DI bind time (passive — runtime checks live in use case body)
- `requiresConsent: ConsentCategory[]` per-use-case manifest field; cross-checked by new ESLint rule `no-undeclared-consent-check`
- `assertFeatureConformance` extended to require `ConsentChecked` brand when `requiresConsent.length > 0`
- Hybrid storage: `users.consentState: UserConsentState` field (fast `isGranted` reads) + `core-audit` `CONSENT_GRANT`/`CONSENT_WITHDRAW` action entries (legal proof history)
- Anonymous → authenticated migration helper `extractAnonymousConsent(cookieHeader)` + `migrateAnonymousConsent({ userId, cookieState })`
- Protocol-agnostic handlers in `core-consent/handlers/`
- tRPC router `core-consent/consent.router.ts` consumed by `core-api`'s appRouter
- React hook `useConsent()` + `<ConsentProvider>` in `core-consent/react`
### Cookie consent banner in `@repo/core-ui`
- **Precondition**: `pnpm turbo gen core-package ui` (core-ui not yet scaffolded — directory exists but empty)
- `<CookieConsentBanner>` headless component with default UI:
- `variant: "modal" | "banner"` prop, default `"modal"`
- Granular category toggles (essential/functional/analytics/marketing by default; consumer extends)
- Equal-prominence Reject All / Accept All buttons (CNIL + EDPB compliance baked into default visual treatment)
- Render-prop overrides for `renderCategoryRow` / `renderActions` / `renderHeader` (default UI works out-of-box; consumer surgically overrides for branding/legal text)
- Reads `IConsent` via `useConsent()` hook from `core-consent/react`
- Manages pre-signup state in `__consent_state` cookie; emits state-changed callback for analytics
- Storybook story doubles as the human-reading-room for compliant cookie UX
### Conformance + manifest changes
- New manifest field per use case: `requiresConsent: ConsentCategory[]`
- New ESLint rule `no-undeclared-consent-check` at warn severity
- `withConsent` wrapper composes innermost — order: `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
- Conformance ESLint rule count: 11 → 12
### Subject linkage on existing collections
- `auth.users`: `custom.subject` defaults to `{ kind: "self", field: "id" }` (template ships explicit declaration for documentation clarity)
- `blog.articles`, `marketing-pages.{site-settings,pages}`, `media.media`, `navigation.header`: no subject linkage needed (no PII fields per Epic A backfill)
- Anchor for future per-feature collections that hold PII about users: documented pattern + example in `docs/compliance/subject-linkage.example.md`
### ADR amendments captured
- **ADR-018**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
- **ADR-024**: `analytics.track` call sites in feature use cases gain a "check consent first" idiom; existing analytics manifest stays unchanged (no `analyticsEvents` re-declaration)
- **`PAYLOAD_AUTH_PII_DEFAULTS`** (from Epic A) gains two excluded fields: `processingRestrictedAt`, `consentState`
### Documentation
- `docs/guides/dsr.md` — full DSR cookbook (interfaces, route wiring, multi-subject handling, soft vs hard delete, certificate format)
- `docs/guides/consent.md` — consent flow cookbook (manifest field, brand, runtime check pattern, anonymous → authenticated migration)
- `docs/glossary.md` — new entries for `SubjectLink`, `DeletionCertificate`, `UserConsentState`, `ConsentChecked` brand
- `CLAUDE.md` + `conformance-quickref.md` — rule count bump (11 → 12) + new manifest field documentation
## Out of scope
- **Pre-launch compliance checklist + fill-in templates** — Epic D
- **Security headers middleware + rate-limit primitive + SBOM** — Epic C
- **Streaming `IDataExport`** — in-memory only for first pass; streaming v2 when a consumer hits OOM
- **REST endpoints** — Epic B exposes only tRPC (matches established pattern); REST wrapping documented for regulators in Epic D
- **Per-framework router auto-wiring for cookie banner pageview reset** — banner emits a `consentChanged` callback; consumer wires their router
- **Anonymous (pre-signup) consent storage in `users.consentState`** — anonymous lives in the cookie until signup migration
- **Strict-mode `ConsentCategory` declaration merging** — string-literal-union escape hatch is sufficient (Q10 of grill)
- **`dsr_rectifications` separate audit collection** — rectifications recorded in main audit log via `reason: "art-16-request"` tag (Q4 of grill)
- **Brand-treatment of `IProcessingRestriction`** — restriction is binary + rare; consumer calls `isRestricted` where needed without a wrapper (Q5 of grill)
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025 (no template ML; revisit when consumer adds automated decisions)
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory
- **Audit-log full-text export for DSR** — included as `auditLog: AuditEntry[]` in `UserDataBundle` but filtered to subject's own events only (other-subject events stay private)
## Constraints
- **ADR-025** — Epic B strategy settled there. Surface deviations require amendment before proceeding.
- **ADR-018** — audit action enum amendment captured in this PRD; reuses existing `core-audit` channel for proof-of-consent + restriction events.
- **ADR-022** — no library-evaluation traces needed for Epic B itself (no new third-party runtime deps); `@repo/core-dsr` and `@repo/core-consent` are workspace packages.
- **ADR-024** — consent gates analytics emission via use case body checks (passive brand pattern); `analytics.track` interface itself unchanged.
- **Epic A's deliverables are dependencies**:
- `custom.pii` tags drive DSR's PII-walking cascade
- `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern is reused for `processingRestrictedAt` + `consentState`
- `compliance/data-map.yml` generator output documents what DSR exports
- **Generator-first** — both new optional cores scaffolded via `pnpm turbo gen core-package <name>`. `core-ui` also requires scaffold (precondition).
- **Manifest-first ordering** — `ConsentCategory` types + `requiresConsent` manifest schema land first; ESLint rule second; runtime wrappers third; use case body migrations last.
- **`core-shared` boundary** — subject-linkage types live in `core-shared/payload/` (must-have, every consumer needs the type to read others' collection configs). DSR + consent interfaces stay in their optional cores.
- **Optional cores composition** — `core-api`'s appRouter composes the new tRPC routers via the existing `<gen:*>` anchor pattern.
- **Consent is template-policy-neutral** — template ships 4 default categories; consumer adds whatever else they need. No template-imposed legal opinion about what requires consent (consumer's DPO decides).
- **No `--no-verify`** — every commit passes pre-commit gates; Conventional Commits non-negotiable.
## Success criteria
- `pnpm turbo gen core-package dsr` and `pnpm turbo gen core-package consent` produce green packages with the documented interfaces.
- `pnpm turbo gen core-package ui` produces a green `core-ui` package; `<CookieConsentBanner>` lands in it.
- `IDataExport.exportSubjectData("alice", "json")` walks the `users` collection + any `custom.subject`-linked collections, returning a `UserDataBundle` with `data.users.asSelf` containing Alice's row and `data.<other>.asReference` for any rows that linked-field her.
- `IDataDelete.deleteSubjectData("alice", "soft")` flips `processingRestrictedAt`, NULLs exportable PII on Alice's `users` row, redacts `assignedTo`-style reference rows to NULL, emits one audit entry per affected collection, returns a `DeletionCertificate`.
- 30 days after a soft-delete, Epic A's retention purge job hard-deletes the row via its existing schedule (no new code in Epic B for this — verifies Epic A's interaction).
- `IConsent.grant("alice", ["analytics"], { method: "banner", bannerVersion: "v1" })` writes a `CONSENT_GRANT` audit entry AND updates `users.consentState.analytics.granted = true`.
- `IConsent.isGranted("alice", "analytics")` returns `true` after the above (reads the cache field).
- A use case declaring `requiresConsent: ["analytics"]` but missing the `withConsent` wrapper at bind time fails `assertFeatureConformance` boot check + the `no-undeclared-consent-check` ESLint rule.
- A `consent.isGranted(_, "marketing")` call site in a use case whose manifest doesn't declare `"marketing"` in `requiresConsent` fires the ESLint rule.
- `<CookieConsentBanner variant="modal">` renders with Reject All / Accept All as equally-sized side-by-side buttons; tab order treats them equally; ARIA labels mirror.
- Anonymous user grants consent via banner → cookie stored → signs up → consumer's `signUp` use case calls `migrateAnonymousConsent` → cookie state lands in `users.consentState` + audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
- tRPC routers `dsrRouter` + `consentRouter` compose into `core-api`'s appRouter without manual wiring (anchor + barrel pattern).
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
- `docs/guides/dsr.md` + `docs/guides/consent.md` cover the consumer wiring paths end-to-end including the anonymous → authenticated migration.
## User stories
1. As a **downstream consumer**, I want `/api/gdpr/export` to walk every PII-tagged collection and return my user's data in JSON so I satisfy Art. 15 without writing custom collection traversal.
2. As a **downstream consumer**, I want `/api/gdpr/delete` to soft-delete a user (immediate flag, 30-day grace, hard-delete via Epic A's purge) so Art. 17 fulfillment is automated.
3. As a **downstream consumer**, I want multi-subject rows (e.g. a support ticket with submitter + assignee) to have their linked PII redacted but the row preserved when one subject requests deletion, so the other subject's data remains intact.
4. As a **downstream consumer running multi-tenant**, I want `custom.subject` to be declarable per-collection so I model my domain's subject relationships explicitly, not assume one `userId` column.
5. As a **downstream consumer**, I want JSON-LD export with schema.org `@context` so a user can port their data to another service (Art. 20) without me writing a portability mapping.
6. As a **downstream consumer**, I want `processing_restricted` to be honored across all my normal use cases via `IProcessingRestriction.isRestricted` checks so Art. 18 enforcement is mechanical.
7. As a **downstream consumer shipping analytics**, I want `requiresConsent: ["analytics"]` on my manifest to enforce that the use case can't bind without consent-checking wrapper, so Art. 7 demonstrable consent is structurally enforced.
8. As a **downstream consumer**, I want `IConsent.grant` to write both the fast-read cache and an audit entry so I have legal proof of consent without slow audit-log queries on every analytics call.
9. As a **downstream consumer**, I want a cookie consent banner with EU-compliant default visual treatment (equal-prominence reject/accept) so I don't accidentally ship a CNIL-violating UX.
10. As a **downstream consumer**, I want the banner to be a headless component with render-prop overrides so I brand the visuals without forking the legal-compliance logic.
11. As an **anonymous user**, I want my consent choices to persist into my account at signup so I don't have to re-consent.
12. As a **compliance reviewer**, I want every consent grant/withdrawal to leave an immutable audit entry recording timestamp, banner version, policy version, and method so I can prove demonstrable consent to a regulator.
13. As an **AI agent** modifying a use case that calls `analytics.track`, I want `no-undeclared-consent-check` to fire when I call `consent.isGranted("X")` without declaring `"X"` in `requiresConsent`, so consent-event drift is caught at lint time.
14. As an **AI agent** scaffolding a new feature, I want `pnpm turbo gen feature` to emit `requiresConsent: []` by default so I declare consent requirements during manifest-first ordering, not as an afterthought.
15. As a **DPO doing internal audit**, I want `compliance/data-map.yml` (from Epic A) and the DSR endpoint mapping (from this Epic) to together answer "what data do we hold + how does a subject act on it" without me reading code.
## Implementation decisions
### Module surface
- **`@repo/core-shared` modifications** (must-have package):
- New module `core-shared/payload/subject-linkage-types.ts` exporting `SubjectLinkKind` (`"self" | "owner" | "reference"`), `SubjectLink`, `CollectionSubject` (single or array form)
- Ambient declaration extending Payload `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
- Extension to `PAYLOAD_AUTH_PII_DEFAULTS` (Epic A's auth-managed exclusions): add `processingRestrictedAt` + `consentState` as excluded (security/control material, not PII-export)
- Audit action enum (in `core-shared/audit`, used by `core-audit`) gains: `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
- **`@repo/core-audit` modifications**:
- `IAuditLog.record` accepts the new action types via the extended enum
- No new interface methods; existing `eraseSubject` flow handles post-DSR-delete pseudonymization
- **`@repo/core-dsr` (new optional core)**:
- Four interfaces in `core-dsr/<interface>.interface.ts`
- Payload-backed reference impls: `PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`
- Recording test doubles in `core-testing`
- Protocol-agnostic handlers in `core-dsr/handlers/{export,delete,rectify,restrict}-handler.ts`
- tRPC router `core-dsr/dsr.router.ts` exporting `dsrRouter`
- JSON-LD context at `core-dsr/contexts/user-data.jsonld` (schema.org-derived)
- DI binders `core-dsr/di/{bind-production,bind-dev-seed}.ts`
- **`@repo/core-consent` (new optional core)**:
- `IConsent` interface in `core-consent/consent.interface.ts`
- `ConsentCategory` + `ConsentState` + `UserConsentState` types in `core-consent/consent-types.ts`
- `withConsent` wrapper attaching `ConsentChecked` brand at bind time
- `ConsentChecked` brand definition added to `core-shared/conformance/brands.ts`
- Payload-backed reference impl `PayloadConsent` (reads/writes `users.consentState` + emits `CONSENT_*` audit entries via injected `core-audit`)
- Recording test double in `core-testing`
- Anonymous migration helpers `extractAnonymousConsent` + `migrateAnonymousConsent`
- Protocol-agnostic handlers in `core-consent/handlers/`
- tRPC router `core-consent/consent.router.ts` exporting `consentRouter`
- React subpath `core-consent/react` with `<ConsentProvider>` + `useConsent()` hook
- DI binders `core-consent/di/{bind-production,bind-dev-seed}.ts`
- **`@repo/core-ui` (new optional core — precondition scaffold)**:
- `pnpm turbo gen core-package ui` lands the package shell first
- `<CookieConsentBanner>` component
- Default render-prop slots: `renderHeader`, `renderCategoryRow`, `renderActions`
- `__consent_state` cookie management (read/write/clear) inside the component for anonymous flow
- Storybook story + accessibility tests (axe-core)
- **`@repo/core-api` modifications**:
- appRouter composes `dsrRouter` + `consentRouter` via existing `<gen:*>` anchor pattern
- **`@repo/core-eslint`**:
- New rule `no-undeclared-consent-check` (warn severity)
- `_manifest-ast.js` parser gains `requiresConsent` field extraction (parallel to `requiresCores`)
- **`@repo/core-testing`**:
- `RecordingDataExport`, `RecordingDataDelete`, `RecordingDataRectify`, `RecordingProcessingRestriction`, `RecordingConsent` test doubles
- **`packages/auth/` modifications**:
- `users` collection: explicit `custom.subject = { kind: "self", field: "id" }` declaration
- Optional `signUp` use case extension (in template's own auth feature): call `migrateAnonymousConsent` when a cookie is present
- **Conformance ESLint rules**: count 11 → 12 (`no-undeclared-consent-check`)
- **Brand composition**: order extended to `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
- **`assertFeatureConformance`**: extended brand check for `ConsentChecked` when `requiresConsent.length > 0`
### Subject linkage contract
```ts
// core-shared/payload/subject-linkage-types.ts
export type SubjectLinkKind = "self" | "owner" | "reference";
export type SubjectLink = {
field: string; // field name (or "id" for self)
kind: SubjectLinkKind;
target?: string; // target collection (required when kind !== "self")
role?: string; // e.g. "submitter", "assignee" — surfaces in DeletionCertificate
};
export type CollectionSubject = SubjectLink | SubjectLink[];
```
DSR cascade semantics by role:
| Role | `IDataExport` | `IDataDelete` mode=`soft` | `IDataDelete` mode=`cascade-hard` |
| ----------- | -------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------- |
| `self` | include row in `asSelf` | NULL exportable fields + set `processingRestrictedAt` | hard-delete row |
| `owner` | include row in `asSelf` | NULL exportable fields | hard-delete row |
| `reference` | include `{rowId, linkedField, linkedThrough}` in `asReference` | redact the linked field only | redact the linked field only (row preserved) |
### Interface contracts
```ts
// core-dsr/data-export.interface.ts
export type UserDataBundle = {
subjectId: string;
exportedAt: string; // ISO 8601
format: "json" | "json-ld";
data: {
[collection: string]: {
asSelf?: Array<Record<string, unknown>>;
asReference?: Array<{
rowId: string;
linkedField: string;
linkedThrough: string;
}>;
};
};
auditLog?: AuditEntry[]; // filtered to subject's own events
"@context"?: string | Record<string, unknown>;
};
export interface IDataExport {
exportSubjectData(
subjectId: string,
format: "json" | "json-ld",
): Promise<UserDataBundle>;
}
// core-dsr/data-delete.interface.ts
export type DeletionCertificate = {
subjectId: string; // or "erased-{hash}" if already scrubbed
mode: "soft" | "cascade-hard";
timestamp: string;
reason: "art-17-request" | "admin-expunge" | "retention-policy";
affected: Array<{
collection: string;
rowsAffected: number;
action: "deleted" | "redacted" | "pseudonymized";
fields?: string[]; // when action === "redacted"
}>;
auditEntryId: string;
};
export interface IDataDelete {
deleteSubjectData(
subjectId: string,
mode: "soft" | "cascade-hard",
): Promise<DeletionCertificate>;
}
// core-dsr/data-rectify.interface.ts
export interface IDataRectify {
updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void>;
}
// core-dsr/processing-restriction.interface.ts
export interface IProcessingRestriction {
setRestriction(subjectId: string, granted: boolean): Promise<void>;
isRestricted(subjectId: string): Promise<boolean>;
}
```
```ts
// core-consent/consent-types.ts
export type ConsentCategory =
| "essential"
| "functional"
| "analytics"
| "marketing"
| (string & Record<never, never>);
export type ConsentState = {
granted: boolean;
grantedAt?: string; // ISO 8601
withdrawnAt?: string;
bannerVersion?: string;
policyVersion?: string;
method?: "banner" | "settings" | "api" | "signup-migration";
};
export type UserConsentState = Record<ConsentCategory, ConsentState>;
// core-consent/consent.interface.ts
export interface IConsent {
isGranted(subjectId: string, category: ConsentCategory): Promise<boolean>;
grant(
subjectId: string,
categories: ConsentCategory[],
record: Omit<ConsentState, "granted" | "grantedAt">,
): Promise<void>;
withdraw(subjectId: string, categories: ConsentCategory[]): Promise<void>;
getCategories(subjectId: string): Promise<ConsentCategory[]>;
}
```
### Cookie banner contract
```tsx
type CookieConsentBannerProps = {
variant?: "modal" | "banner"; // default "modal"
categories?: ConsentCategory[]; // default ["essential", "functional", "analytics", "marketing"]
defaultEnabled?: ConsentCategory[]; // default ["essential"]
privacyPolicyHref?: string;
bannerVersion?: string;
policyVersion?: string;
onConsentChange?: (state: UserConsentState) => void;
renderHeader?: (ctx: { variant: "modal" | "banner" }) => ReactNode;
renderCategoryRow?: (ctx: {
category: ConsentCategory;
granted: boolean;
toggle: () => void;
}) => ReactNode;
renderActions?: (ctx: {
acceptAll: () => void;
rejectAll: () => void;
saveSelected: () => void;
}) => ReactNode;
};
```
Default UI ships:
- Modal variant: centered, focus-trapped, ESC = "reject all" (legally explicit choice — not silent dismiss)
- Banner variant: bottom-of-viewport, position fixed, full-width
- Reject All + Accept All: side-by-side, equal size, equal visual weight (no "Accept All" emphasis via color/size)
- Save Selected: secondary action
a11y baseline:
- WCAG 2.2 AA color contrast
- Keyboard navigable (tab/shift-tab/escape)
- Screen-reader announcements via ARIA live region
- Focus-trap inside modal
- axe-core test in Storybook
### Anonymous → authenticated consent migration
Flow:
1. Banner saves anonymous consent to `__consent_state` cookie (SameSite=Lax, Secure, 1-year max-age).
2. Consumer's `signUp` use case extracts the cookie via `extractAnonymousConsent(request.headers.cookie)`.
3. After user record creation, calls `migrateAnonymousConsent({ userId, cookieState, banneredVersion, policyVersion })`.
4. Helper calls `IConsent.grant(userId, cookieState.categories, { method: "signup-migration", bannerVersion, policyVersion })`.
5. Audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
6. Response sets `Set-Cookie: __consent_state=; Max-Age=0` (clear cookie).
Helper signatures live in `core-consent`; the template's existing `auth.signUp` use case is the canonical example consumer.
### tRPC router shapes
- `dsrRouter.export``{ subjectId, format }``UserDataBundle`
- `dsrRouter.delete``{ subjectId, mode }``DeletionCertificate`
- `dsrRouter.rectify``{ subjectId, collection, field, value }``void`
- `dsrRouter.restrict``{ subjectId, granted }``void`
- `consentRouter.grant``{ subjectId, categories, record }``void`
- `consentRouter.withdraw``{ subjectId, categories }``void`
- `consentRouter.isGranted``{ subjectId, category }``boolean`
- `consentRouter.getCategories``{ subjectId }``ConsentCategory[]`
Auth check happens at the procedure level via existing per-feature error middleware pattern (defineErrorMiddleware). Subject-driven calls: `subjectId === ctx.session.user.id`. Admin calls: role check via existing auth feature mechanisms.
### ADR amendments captured
- **ADR-018 amendment**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`. The PRD landing this change adds the lines to `core-shared/audit/audit-action.ts` (or wherever the enum lives) and updates `docs/guides/audit-and-compliance.md`. ADR-018's own §"Six action types" wording becomes "ten action types"; an `## Amendments` section at the foot of ADR-018 captures the date + reason.
### Conformance + manifest impact
- New per-use-case field: `requiresConsent: ConsentCategory[]` (default `[]`)
- New ESLint rule: `no-undeclared-consent-check` (warn)
- Brand composition order extended (innermost-to-outermost): `withConsent``withAnalytics``withAudit``withCapture``withSpan`
- Boot assertion: requires `ConsentChecked` brand when `requiresConsent.length > 0`
- New optional cores in `requiredCores` vocabulary: `dsr`, `consent`
- `required-cores-installed` ESLint rule auto-detects new optional cores via existing pnpm-workspace.yaml mechanism
### Endpoint mapping (informative — not code)
| GDPR Article | tRPC procedure | HTTP path (consumer-mapped) |
| ------------------------ | ----------------------------------------- | --------------------------------- |
| Art. 15 (access) | `dsrRouter.export({ format: "json" })` | `/api/gdpr/export` |
| Art. 16 (rectification) | `dsrRouter.rectify` | `/api/gdpr/rectify` |
| Art. 17 (erasure) | `dsrRouter.delete` | `/api/gdpr/delete` |
| Art. 18 (restriction) | `dsrRouter.restrict` | `/api/gdpr/restrict` |
| Art. 20 (portability) | `dsrRouter.export({ format: "json-ld" })` | `/api/gdpr/export?format=json-ld` |
| Art. 21 (objection) | `consentRouter.withdraw` | `/api/consent/withdraw` |
| Art. 22 (auto decisions) | _deferred_ per ADR-025 | _deferred_ |
| Art. 7 (consent grant) | `consentRouter.grant` | `/api/consent/grant` |
The mapping is documentation; consumer composes their own HTTP namespace from the tRPC procedures.
## Testing decisions
- **Interface impls** (`PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`, `PayloadConsent`): each gets a contract test suite covering: happy path per role/mode, multi-subject row redaction, JSON-LD `@context` correctness, audit emission shape, restriction flag honored on reads, consent state read/write round-trip including audit entry, signup-migration helper.
- **Recording doubles**: vitest unit tests asserting captured calls match invocations + payload shape.
- **`withConsent` wrapper**: tests asserting `ConsentChecked` brand attached at bind time, brand inspectable via `isConsentChecked` helper, factory invocation passthrough preserved.
- **ESLint rule `no-undeclared-consent-check`**: RuleTester fixtures parallel to `no-undeclared-audit.test.js` — passes when call matches manifest, fires on undeclared category, fires on unused declaration (warn), no-op on non-use-case files.
- **`assertFeatureConformance` brand check**: synthetic manifest fixture with `requiresConsent: ["analytics"]` but no `withConsent` wrapper at bind site fails with a `ConformanceError` naming the missing `ConsentChecked` brand.
- **Cookie banner**:
- Storybook story with axe-core a11y pass
- React Testing Library: render → click "Reject All" → assert callback fires with all categories `granted: false` except `essential`
- Render → toggle analytics → click "Save Selected" → assert callback fires with `analytics.granted: true`
- Modal variant focus-trap test
- Keyboard nav test (tab order, escape behavior)
- **Signup migration**: in `auth.signUp.use-case.test.ts` — mock cookie header → call use case → assert `migrateAnonymousConsent` invoked → assert audit entry shape via `RecordingAuditLog`
- **tRPC routers**: integration tests using the existing tRPC test pattern (defineErrorMiddleware passthrough, auth check, response shape)
- **Multi-subject scenario**: synthetic Payload collection fixture (`support_tickets`) declared in a unit test with `custom.subject = [{ field: "submittedBy", role: "submitter", kind: "reference" }, { field: "assignedTo", role: "assignee", kind: "reference" }]`. Export for one user returns the linked-field reference; delete redacts only that user's link.
- **JSON-LD `@context` correctness**: test asserts emitted JSON-LD parses with a standard schema.org validator (use `jsonld` library in test only, not runtime).
- **Coverage**: all new modules join L0 vitest thresholds via `coverage.bands` in their new feature manifests. L1 `pnpm coverage:diff` gates every slice.
- **Prior art to mirror**:
- `core-audit` interface + DI binder + recording double pattern: `packages/core-audit/src/{audit-log.interface.ts,di/bind-audit.ts}` + `packages/core-testing/src/instrumentation/recording-audit-log.ts`
- ESLint rule: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
- Brand attachment: `packages/core-shared/src/conformance/{brands.ts,brand-runtime.ts}` + `packages/core-shared/src/instrumentation/with-capture.ts`
- tRPC router pattern: any feature's `integrations/api/router.ts`
- React subpath: ADR-024's `@repo/core-analytics/react` (`<AnalyticsProvider>` + `useAnalytics()` hook) — shipped this session
## Open questions
- **Q1: Multi-subject `IDataExport` JSON-LD `@context` — separate context per `role` or one context?** — Recommended: **one context**. schema.org's `Role` vocabulary handles role-naming via `roleName`. Simpler than per-role context selection.
- **Q2: Should `IDataDelete` mode=`cascade-hard` skip the 30-day grace and execute immediately?** — Recommended: **yes**. Admin-only path (auth check at endpoint level). Soft-delete handles the user-driven 30-day flow; cascade-hard is for "expunge now" scenarios (legal hold release, court order). Document in `docs/guides/dsr.md`.
- **Q3: How does `<CookieConsentBanner>` handle SSR — render server-side or client-only?** — Recommended: **client-only with SSR-safe placeholder**. Component reads/writes cookies; server can't reliably synthesize the right initial state. Ship `<CookieConsentBannerLoader>` for SSR placeholder + dynamic-import the actual banner client-side. Document in `docs/guides/consent.md`.
- **Q4: Should `IConsent.grant` accept an opaque `ipTruncated` field for audit (matches `core-audit`'s from-where requirement)?** — Recommended: **yes**, but threaded via the handler layer (extracts from request), not via `IConsent`'s own signature. Keep the interface PII-clean; the handler enriches the audit entry with truncated IP from the request context.
- **Q5: Does the cookie banner's `__consent_state` cookie shape need to be versioned for forward-compat?** — Recommended: **yes, include `_v: 1` field**. Component reads versioned shape; migrates older versions on read. Documents the migration policy in `docs/guides/consent.md`.
## Out of scope (deferred)
- **REST endpoint scaffolds** — exposed via tRPC only per Q8 of grill; consumer wraps for REST if needed; Epic D docs the wrapping pattern.
- **Streaming `IDataExport`** — in-memory `UserDataBundle` for first pass; v2 if a consumer hits OOM (see Q3 of grill).
- **`dsr_rectifications` collection for rectification history** — main audit log suffices via `reason: "art-16-request"` tag (Q4 of grill).
- **`withRestriction` brand-treatment** — restriction is binary + rare; consumer calls `isRestricted` where needed (Q5 of grill).
- **Strict-mode `ConsentCategory` declaration merging** — escape-hatch union is sufficient (Q10 of grill).
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025; revisit when consumer adds automated decisions.
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory.
- **Audit-log full export across subjects (admin-mediated forensics)** — out of DSR's subject-scoped surface.
- **Migration tooling for downstream consumers upgrading from a pre-ADR-025 template version** — template hasn't been versioned with consumers; no migration story yet.
- **Per-framework router auto-wiring for analytics on consent toggle** — banner emits `onConsentChange` callback; consumer wires (e.g., re-initialize analytics SDK after consent granted).
## Further notes
- **Builds on**: ADR-018 (audit channel — amends action enum), ADR-022 (library evaluation policy — no new traces needed for workspace packages), ADR-024 (analytics — gates emission via consent), ADR-025 (strategy umbrella — Epic B implementation seed), Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — provides PII tags + retention purge + `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern).
- **Pairs with**: Epic C PRD `security-headers-rate-limit-sbom.prd.md` (independent; dispatcher can interleave); Epic D PRD `compliance-docs-scaffolds.prd.md` (lands after B; consumes DSR + consent route mappings in documentation).
- **Sequencing within Epic B**: subject-linkage types + ambient declaration → audit enum amendment → `core-consent` interface + brand + ESLint rule → `core-dsr` interfaces + handlers → tRPC routers + core-api composition → cookie banner (after core-ui scaffolded) → signup-migration helper + auth feature integration → docs.
- **Stakeholders**: template authors (most affected — adds 2 optional cores, manifest field, brand, ESLint rule, ADR-018 amendment), downstream EU-bound consumers (positively affected — gain DSR + consent + cookie banner for free), AI agents operating in feature code (positively affected — declarative consent gates with lint-time enforcement), compliance reviewers + DPOs (positively affected — demonstrable consent with audit-logged proof).
- **PII boundary clarification**: this PRD does NOT relax ADR-017 §7. Observability surface stays id-only. Consent surface explicitly permits traits within `UserConsentState` (banner version, policy version, method, IP-truncated via handler enrichment) — consumer-policy boundary distinct from observability boundary, documented per ADR-024's PII boundary precedent.