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.
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.trackfrom 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 + 20IDataDelete.deleteSubjectData(subjectId, mode: "soft" | "cascade-hard"): Promise<DeletionCertificate>— Art. 17IDataRectify.updateSubjectField(subjectId, collection, field, value): Promise<void>— Art. 16IProcessingRestriction.{setRestriction, isRestricted}— Art. 18
- Payload-backed reference impls walking Epic A's
custom.piitags and the new collection-levelcustom.subjectlinkage - Protocol-agnostic handlers in
core-dsr/handlers/returning normalized{ status, body, headers } - tRPC router
core-dsr/dsr.router.tsconsumed bycore-api's appRouter - Subject-linkage TypeScript types in
core-shared/payload/:SubjectLink,SubjectLinkRole,CollectionSubject - Ambient declaration extending Payload's
CollectionConfig.custom?withsubject?: CollectionSubject | CollectionSubject[] - Default schema.org JSON-LD
@contextshipped atcore-dsr/contexts/user-data.jsonld; consumer-overridable DeletionCertificatetype 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
ConsentCategorystring-literal union with escape hatch (matchesPiiCategorypattern):"essential" | "functional" | "analytics" | "marketing" | (string & Record<never, never>)withConsentwrapper attachingConsentCheckedbrand at DI bind time (passive — runtime checks live in use case body)requiresConsent: ConsentCategory[]per-use-case manifest field; cross-checked by new ESLint ruleno-undeclared-consent-checkassertFeatureConformanceextended to requireConsentCheckedbrand whenrequiresConsent.length > 0- Hybrid storage:
users.consentState: UserConsentStatefield (fastisGrantedreads) +core-auditCONSENT_GRANT/CONSENT_WITHDRAWaction 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.tsconsumed bycore-api's appRouter - React hook
useConsent()+<ConsentProvider>incore-consent/react
Cookie consent banner in @repo/core-ui
- Precondition:
pnpm turbo gen core-package ui(core-ui not yet scaffolded — directory exists but empty) <CookieConsentBanner>headless component with default UI:variant: "modal" | "banner"prop, default"modal"- Granular category toggles (essential/functional/analytics/marketing by default; consumer extends)
- Equal-prominence Reject All / Accept All buttons (CNIL + EDPB compliance baked into default visual treatment)
- Render-prop overrides for
renderCategoryRow/renderActions/renderHeader(default UI works out-of-box; consumer surgically overrides for branding/legal text) - Reads
IConsentviauseConsent()hook fromcore-consent/react - Manages pre-signup state in
__consent_statecookie; 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-checkat warn severity withConsentwrapper composes innermost — order:withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)- Conformance ESLint rule count: 11 → 12
Subject linkage on existing collections
auth.users:custom.subjectdefaults 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.trackcall sites in feature use cases gain a "check consent first" idiom; existing analytics manifest stays unchanged (noanalyticsEventsre-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 forSubjectLink,DeletionCertificate,UserConsentState,ConsentCheckedbrandCLAUDE.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
consentChangedcallback; consumer wires their router - Anonymous (pre-signup) consent storage in
users.consentState— anonymous lives in the cookie until signup migration - Strict-mode
ConsentCategorydeclaration merging — string-literal-union escape hatch is sufficient (Q10 of grill) dsr_rectificationsseparate audit collection — rectifications recorded in main audit log viareason: "art-16-request"tag (Q4 of grill)- Brand-treatment of
IProcessingRestriction— restriction is binary + rare; consumer callsisRestrictedwhere 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[]inUserDataBundlebut 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-auditchannel 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-dsrand@repo/core-consentare workspace packages. - ADR-024 — consent gates analytics emission via use case body checks (passive brand pattern);
analytics.trackinterface itself unchanged. - Epic A's deliverables are dependencies:
custom.piitags drive DSR's PII-walking cascadePAYLOAD_AUTH_PII_DEFAULTSextension pattern is reused forprocessingRestrictedAt+consentStatecompliance/data-map.ymlgenerator output documents what DSR exports
- Generator-first — both new optional cores scaffolded via
pnpm turbo gen core-package <name>.core-uialso requires scaffold (precondition). - Manifest-first ordering —
ConsentCategorytypes +requiresConsentmanifest schema land first; ESLint rule second; runtime wrappers third; use case body migrations last. core-sharedboundary — subject-linkage types live incore-shared/payload/(must-have, every consumer needs the type to read others' collection configs). DSR + consent interfaces stay in their optional cores.- Optional cores composition —
core-api's appRouter composes the new tRPC routers via the existing<gen:*>anchor pattern. - Consent is template-policy-neutral — template ships 4 default categories; consumer adds whatever else they need. No template-imposed legal opinion about what requires consent (consumer's DPO decides).
- No
--no-verify— every commit passes pre-commit gates; Conventional Commits non-negotiable.
Success criteria
pnpm turbo gen core-package dsrandpnpm turbo gen core-package consentproduce green packages with the documented interfaces.pnpm turbo gen core-package uiproduces a greencore-uipackage;<CookieConsentBanner>lands in it.IDataExport.exportSubjectData("alice", "json")walks theuserscollection + anycustom.subject-linked collections, returning aUserDataBundlewithdata.users.asSelfcontaining Alice's row anddata.<other>.asReferencefor any rows that linked-field her.IDataDelete.deleteSubjectData("alice", "soft")flipsprocessingRestrictedAt, NULLs exportable PII on Alice'susersrow, redactsassignedTo-style reference rows to NULL, emits one audit entry per affected collection, returns aDeletionCertificate.- 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 aCONSENT_GRANTaudit entry AND updatesusers.consentState.analytics.granted = true.IConsent.isGranted("alice", "analytics")returnstrueafter the above (reads the cache field).- A use case declaring
requiresConsent: ["analytics"]but missing thewithConsentwrapper at bind time failsassertFeatureConformanceboot check + theno-undeclared-consent-checkESLint rule. - A
consent.isGranted(_, "marketing")call site in a use case whose manifest doesn't declare"marketing"inrequiresConsentfires 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
signUpuse case callsmigrateAnonymousConsent→ cookie state lands inusers.consentState+ audit emitsCONSENT_GRANTwithmethod: "signup-migration". - tRPC routers
dsrRouter+consentRoutercompose intocore-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 --checkall green at every commit boundary.docs/guides/dsr.md+docs/guides/consent.mdcover the consumer wiring paths end-to-end including the anonymous → authenticated migration.
User stories
- As a downstream consumer, I want
/api/gdpr/exportto walk every PII-tagged collection and return my user's data in JSON so I satisfy Art. 15 without writing custom collection traversal. - As a downstream consumer, I want
/api/gdpr/deleteto soft-delete a user (immediate flag, 30-day grace, hard-delete via Epic A's purge) so Art. 17 fulfillment is automated. - 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.
- As a downstream consumer running multi-tenant, I want
custom.subjectto be declarable per-collection so I model my domain's subject relationships explicitly, not assume oneuserIdcolumn. - As a downstream consumer, I want JSON-LD export with schema.org
@contextso a user can port their data to another service (Art. 20) without me writing a portability mapping. - As a downstream consumer, I want
processing_restrictedto be honored across all my normal use cases viaIProcessingRestriction.isRestrictedchecks so Art. 18 enforcement is mechanical. - 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. - As a downstream consumer, I want
IConsent.grantto 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. - 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.
- 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.
- As an anonymous user, I want my consent choices to persist into my account at signup so I don't have to re-consent.
- 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.
- As an AI agent modifying a use case that calls
analytics.track, I wantno-undeclared-consent-checkto fire when I callconsent.isGranted("X")without declaring"X"inrequiresConsent, so consent-event drift is caught at lint time. - As an AI agent scaffolding a new feature, I want
pnpm turbo gen featureto emitrequiresConsent: []by default so I declare consent requirements during manifest-first ordering, not as an afterthought. - 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-sharedmodifications (must-have package):- New module
core-shared/payload/subject-linkage-types.tsexportingSubjectLinkKind("self" | "owner" | "reference"),SubjectLink,CollectionSubject(single or array form) - Ambient declaration extending Payload
CollectionConfig.custom?withsubject?: CollectionSubject | CollectionSubject[] - Extension to
PAYLOAD_AUTH_PII_DEFAULTS(Epic A's auth-managed exclusions): addprocessingRestrictedAt+consentStateas excluded (security/control material, not PII-export) - Audit action enum (in
core-shared/audit, used bycore-audit) gains:CONSENT_GRANT,CONSENT_WITHDRAW,RESTRICT,UNRESTRICT
- New module
@repo/core-auditmodifications:IAuditLog.recordaccepts the new action types via the extended enum- No new interface methods; existing
eraseSubjectflow 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.tsexportingdsrRouter - JSON-LD context at
core-dsr/contexts/user-data.jsonld(schema.org-derived) - DI binders
core-dsr/di/{bind-production,bind-dev-seed}.ts
- Four interfaces in
@repo/core-consent(new optional core):IConsentinterface incore-consent/consent.interface.tsConsentCategory+ConsentState+UserConsentStatetypes incore-consent/consent-types.tswithConsentwrapper attachingConsentCheckedbrand at bind timeConsentCheckedbrand definition added tocore-shared/conformance/brands.ts- Payload-backed reference impl
PayloadConsent(reads/writesusers.consentState+ emitsCONSENT_*audit entries via injectedcore-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.tsexportingconsentRouter - React subpath
core-consent/reactwith<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 uilands the package shell first<CookieConsentBanner>component- Default render-prop slots:
renderHeader,renderCategoryRow,renderActions __consent_statecookie management (read/write/clear) inside the component for anonymous flow- Storybook story + accessibility tests (axe-core)
@repo/core-apimodifications:- appRouter composes
dsrRouter+consentRoutervia existing<gen:*>anchor pattern
- appRouter composes
@repo/core-eslint:- New rule
no-undeclared-consent-check(warn severity) _manifest-ast.jsparser gainsrequiresConsentfield extraction (parallel torequiresCores)
- New rule
@repo/core-testing:RecordingDataExport,RecordingDataDelete,RecordingDataRectify,RecordingProcessingRestriction,RecordingConsenttest doubles
packages/auth/modifications:userscollection: explicitcustom.subject = { kind: "self", field: "id" }declaration- Optional
signUpuse case extension (in template's own auth feature): callmigrateAnonymousConsentwhen 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 forConsentCheckedwhenrequiresConsent.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[]>;
}
Cookie banner contract
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:
- Banner saves anonymous consent to
__consent_statecookie (SameSite=Lax, Secure, 1-year max-age). - Consumer's
signUpuse case extracts the cookie viaextractAnonymousConsent(request.headers.cookie). - After user record creation, calls
migrateAnonymousConsent({ userId, cookieState, banneredVersion, policyVersion }). - Helper calls
IConsent.grant(userId, cookieState.categories, { method: "signup-migration", bannerVersion, policyVersion }). - Audit emits
CONSENT_GRANTwithmethod: "signup-migration". - 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 }→UserDataBundledsrRouter.delete→{ subjectId, mode }→DeletionCertificatedsrRouter.rectify→{ subjectId, collection, field, value }→voiddsrRouter.restrict→{ subjectId, granted }→voidconsentRouter.grant→{ subjectId, categories, record }→voidconsentRouter.withdraw→{ subjectId, categories }→voidconsentRouter.isGranted→{ subjectId, category }→booleanconsentRouter.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 tocore-shared/audit/audit-action.ts(or wherever the enum lives) and updatesdocs/guides/audit-and-compliance.md. ADR-018's own §"Six action types" wording becomes "ten action types"; an## Amendmentssection 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
ConsentCheckedbrand whenrequiresConsent.length > 0 - New optional cores in
requiredCoresvocabulary:dsr,consent required-cores-installedESLint 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@contextcorrectness, 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.
withConsentwrapper: tests assertingConsentCheckedbrand attached at bind time, brand inspectable viaisConsentCheckedhelper, factory invocation passthrough preserved.- ESLint rule
no-undeclared-consent-check: RuleTester fixtures parallel tono-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. assertFeatureConformancebrand check: synthetic manifest fixture withrequiresConsent: ["analytics"]but nowithConsentwrapper at bind site fails with aConformanceErrornaming the missingConsentCheckedbrand.- Cookie banner:
- Storybook story with axe-core a11y pass
- React Testing Library: render → click "Reject All" → assert callback fires with all categories
granted: falseexceptessential - 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 → assertmigrateAnonymousConsentinvoked → assert audit entry shape viaRecordingAuditLog - 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 withcustom.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
@contextcorrectness: test asserts emitted JSON-LD parses with a standard schema.org validator (usejsonldlibrary in test only, not runtime). - Coverage: all new modules join L0 vitest thresholds via
coverage.bandsin their new feature manifests. L1pnpm coverage:diffgates every slice. - Prior art to mirror:
core-auditinterface + 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
IDataExportJSON-LD@context— separate context perroleor one context? — Recommended: one context. schema.org'sRolevocabulary handles role-naming viaroleName. Simpler than per-role context selection. - Q2: Should
IDataDeletemode=cascade-hardskip 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 indocs/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 indocs/guides/consent.md. - Q4: Should
IConsent.grantaccept an opaqueipTruncatedfield for audit (matchescore-audit's from-where requirement)? — Recommended: yes, but threaded via the handler layer (extracts from request), not viaIConsent'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_statecookie shape need to be versioned for forward-compat? — Recommended: yes, include_v: 1field. Component reads versioned shape; migrates older versions on read. Documents the migration policy indocs/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-memoryUserDataBundlefor first pass; v2 if a consumer hits OOM (see Q3 of grill). dsr_rectificationscollection for rectification history — main audit log suffices viareason: "art-16-request"tag (Q4 of grill).withRestrictionbrand-treatment — restriction is binary + rare; consumer callsisRestrictedwhere needed (Q5 of grill).- Strict-mode
ConsentCategorydeclaration 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
onConsentChangecallback; 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_DEFAULTSextension pattern). - Pairs with: Epic C PRD
security-headers-rate-limit-sbom.prd.md(independent; dispatcher can interleave); Epic D PRDcompliance-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-consentinterface + brand + ESLint rule →core-dsrinterfaces + 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.