--- 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` — covers Arts. 15 + 20 - `IDataDelete.deleteSubjectData(subjectId, mode: "soft" | "cascade-hard"): Promise` — Art. 17 - `IDataRectify.updateSubjectField(subjectId, collection, field, value): Promise` — 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)` - `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()` + `` 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) - `` 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 `. `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 `` 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; `` 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..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. - `` 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.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 `` + `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 - `` 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 `` 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>; asReference?: Array<{ rowId: string; linkedField: string; linkedThrough: string; }>; }; }; auditLog?: AuditEntry[]; // filtered to subject's own events "@context"?: string | Record; }; export interface IDataExport { exportSubjectData( subjectId: string, format: "json" | "json-ld", ): Promise; } // 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; } // core-dsr/data-rectify.interface.ts export interface IDataRectify { updateSubjectField( subjectId: string, collection: string, field: string, value: unknown, ): Promise; } // core-dsr/processing-restriction.interface.ts export interface IProcessingRestriction { setRestriction(subjectId: string, granted: boolean): Promise; isRestricted(subjectId: string): Promise; } ``` ```ts // core-consent/consent-types.ts export type ConsentCategory = | "essential" | "functional" | "analytics" | "marketing" | (string & Record); 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; // core-consent/consent.interface.ts export interface IConsent { isGranted(subjectId: string, category: ConsentCategory): Promise; grant( subjectId: string, categories: ConsentCategory[], record: Omit, ): Promise; withdraw(subjectId: string, categories: ConsentCategory[]): Promise; getCategories(subjectId: string): Promise; } ``` ### 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` (`` + `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 `` 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 `` 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.