Files
agentic-dev/docs/work/prds/dsr-consent-and-cookie-banner.prd.md
Danijel Martinek ae886a4499 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.
2026-05-19 11:41:25 +02:00

37 KiB

id, title, type, status, author, created, updated
id title type status author created updated
dsr-consent-and-cookie-banner DSR + consent abstraction + cookie consent banner — Epic B of ADR-025 prd approved danijel 2026-05-19T09:36:42Z 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
  • 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
  • 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 orderingConsentCategory 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 compositioncore-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

// 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

// 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>;
}
// 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[]>;
}
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

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): withConsentwithAnalyticswithAuditwithCapturewithSpan
  • 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.