diff --git a/docs/work/_system/_state.json b/docs/work/_system/_state.json index 88e5a98..0377cd6 100644 --- a/docs/work/_system/_state.json +++ b/docs/work/_system/_state.json @@ -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", diff --git a/docs/work/prds/dsr-consent-and-cookie-banner.prd.md b/docs/work/prds/dsr-consent-and-cookie-banner.prd.md new file mode 100644 index 0000000..df01106 --- /dev/null +++ b/docs/work/prds/dsr-consent-and-cookie-banner.prd.md @@ -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` — 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.