Files
agentic-dev/docs/guides/consent.md
Danijel Martinek a3505f2e69 docs(compliance): add DSR guide, consent guide, subject-linkage example, glossary terms
- docs/guides/dsr.md: GDPR Art. 15/16/17/18/20 interface mapping, tRPC
  router wiring, multi-subject handling, soft vs cascade-hard semantics,
  DeletionCertificate format and storage requirements
- docs/guides/consent.md: requiresConsent manifest field, withConsent DI
  wiring, runtime isGranted pattern, IConsent audit trail, anonymous→
  authenticated migration, cookie _v versioning, SSR-safe banner loading,
  CNIL/EDPB equal-prominence requirement
- docs/compliance/subject-linkage.example.md: SubjectLink kind discriminator
  with worked support-ticket example (owner submitter + reference assignee)
- docs/glossary.md: SubjectLink, DeletionCertificate, UserConsentState,
  ConsentChecked entries; Manifest definition updated with requiresConsent
- CLAUDE.md: lint comment 8→12 conformance rules; conformance section notes
  requiresConsent; brand composition order updated to full 5-wrapper chain
- docs/guides/conformance-quickref.md: requiresConsent field added to
  manifest table; component-must-have-story, component-must-have-test,
  atomic-tier-import-direction added to ESLint rules table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:07:50 +00:00

256 lines
9.6 KiB
Markdown

# Consent guide
Consumer-facing reference for the `@repo/core-consent` optional core package. Covers the `requiresConsent` manifest field, `withConsent` DI wiring, runtime consent-check pattern, `IConsent` interface, anonymous→authenticated migration, cookie versioning policy, SSR-safe banner loading, and CNIL/EDPB equal-prominence requirements.
**Prerequisite:** scaffold the package if it isn't already present:
```bash
pnpm turbo gen core-package consent
```
---
## Declaring `requiresConsent` in the feature manifest
Any feature that gates behaviour behind user consent declares the required categories in `feature.manifest.ts`:
```ts
// packages/<feature>/src/feature.manifest.ts
export const fooManifest = defineFeature({
name: "foo",
requiredCores: ["consent"],
requiresConsent: ["analytics", "marketing"],
useCases: {
trackEvent: { mutates: false, audits: [], publishes: [], consumes: [] },
},
// ...
} as const);
```
`requiresConsent` accepts an array of `ConsentCategory` values:
| Category | Typical use |
| -------------- | ----------------------------------------------- |
| `"necessary"` | Session management, CSRF protection (always on) |
| `"functional"` | Preferences, language, personalised content |
| `"analytics"` | Product analytics, funnels, cohort analysis |
| `"marketing"` | Ad targeting, remarketing pixels |
| `string & {}` | Custom categories (autocomplete still works) |
The ESLint rule `conformance/no-undeclared-consent-check` warns if:
- A use-case file calls `consent.isGranted("X")` for a category not in `manifest.requiresConsent`.
- `manifest.requiresConsent` lists a category that is never checked in any use case.
---
## Wiring `withConsent` at DI bind time
Apply `withConsent` **inside** `withCapture`, **outermost** among the optional wrappers:
```ts
// packages/<feature>/src/di/bind-production.ts
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
import { withConsent } from "@repo/core-consent";
export function bindProductionFoo(ctx: BindProductionContext): void {
const { tracer, logger, consent } = ctx;
// Full composition order (outermost → innermost):
// withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)
fooContainer
.bind<ITrackEventUseCase>(FOO_SYMBOLS.ITrackEventUseCase)
.toDynamicValue(() =>
withSpan(
tracer,
{ name: "foo.trackEvent" },
withCapture(
logger,
{ useCase: "foo.trackEvent" },
withConsent(consent, trackEventUseCase({ analytics: ctx.analytics })),
),
),
);
assertFeatureConformance(
fooContainer,
fooManifest,
{ trackEvent: FOO_SYMBOLS.ITrackEventUseCase },
ctx,
);
}
```
`withConsent` attaches the `ConsentChecked` brand at bind time. The boot assertion (`assertFeatureConformance`) verifies that every use case listed under `requiresConsent` in the manifest is wrapped. TypeScript rejects binding an unwrapped factory to a `ConsentChecked`-typed symbol.
---
## Runtime consent-check pattern
Inside a use case, call `consent.isGranted(category)` before performing the gated work:
```ts
// packages/<feature>/src/application/use-cases/track-event.use-case.ts
import type { IConsent } from "@repo/core-consent";
import { ConsentDeniedError } from "@repo/core-consent/errors";
export function trackEventUseCase(deps: {
analytics: IAnalytics;
consent: IConsent;
}) {
return async (input: TrackEventInput): Promise<void> => {
if (!deps.consent.isGranted("analytics")) {
throw new ConsentDeniedError("analytics");
}
await deps.analytics.track(input.event, input.properties);
};
}
```
`isGranted` is **synchronous** — it reads the in-memory consent state populated at request initialisation. Do not `await` it.
---
## `IConsent` interface
```ts
interface IConsent {
/** Synchronous: reads in-memory state. */
isGranted(category: ConsentCategory): boolean;
/** Records a consent grant; production impl writes to the audit log. */
grant(category: ConsentCategory, meta?: ConsentGrantMeta): Promise<void>;
/** Records a consent withdrawal; production impl writes to the audit log. */
withdraw(category: ConsentCategory): Promise<void>;
/** Returns per-category state for all categories the subject has interacted with. */
getCategories(): Promise<UserConsentState[]>;
}
```
### Audit trail
The production `IConsent` implementation calls `auditLog.record(...)` on every `grant` and `withdraw` call, creating an immutable audit entry with:
- `type`: `"consent.granted"` or `"consent.withdrawn"`
- `category`: the `ConsentCategory` string
- `method`: from `ConsentGrantMeta.method` (e.g., `"banner-accept"`, `"signup-migration"`)
- `bannerVersion` / `policyVersion`: from `ConsentGrantMeta` when provided
This creates a traceable consent history for regulatory inspection.
---
## Anonymous → authenticated migration
When an anonymous visitor accepts the cookie banner, their choices are stored in the `cc_consent` cookie as a comma-separated list of granted categories (e.g., `necessary,analytics`).
On sign-up or sign-in, migrate the anonymous consent to the authenticated user record:
```ts
// packages/auth/src/application/use-cases/sign-up.use-case.ts
import {
extractAnonymousConsent,
migrateAnonymousConsent,
} from "@repo/core-consent/migration";
export function signUpUseCase(deps: {
users: IUsersRepository;
consent: IConsent;
}) {
return async (input: SignUpInput): Promise<SignUpOutput> => {
const user = await deps.users.create({ email: input.email /* ... */ });
// Migrate consent from the anonymous banner cookie, if present.
const cookieState = extractAnonymousConsent(input.cookieHeader ?? "");
await migrateAnonymousConsent({
consent: deps.consent,
cookieState,
bannerVersion: input.bannerVersion,
policyVersion: input.policyVersion,
});
return signUpOutputSchema.parse({ userId: user.id });
};
}
```
`migrateAnonymousConsent` calls `consent.grant(category, { method: "signup-migration" })` for each category in the cookie. It is a no-op when `cookieState` is `null`.
After migration, delete or expire the `cc_consent` cookie on the client to avoid double-migration on subsequent sign-ins.
---
## Cookie versioning policy
The client-side consent state is stored in the `__consent_state` cookie as JSON:
```ts
type ConsentCookieState = {
_v: number; // schema version (bump when categories change)
categories: Record<string, boolean>; // category slug → granted
};
```
**When to increment `_v`:**
- You add a new non-necessary consent category.
- You update the privacy policy in a way that requires renewed consent (e.g., new processing purpose).
- You remove a category that users previously consented to and need to inform them of the change.
**Migration-on-read:** when `readConsentCookie()` reads a cookie with `_v < current`, treat the consent as `pending` and show the banner again. The user's previous granular choices are discarded because the categories or policy changed. Implement this in your `ConsentProvider` initialisation.
The `cc_consent` anonymous cookie (written before authentication) follows the same versioning scheme — pass `bannerVersion` through `extractAnonymousConsent` / `migrateAnonymousConsent` so the version is preserved in the audit log.
---
## SSR-safe banner loading
The cookie consent banner is a client-only component (reads `document.cookie`, attaches focus traps). Use `CookieConsentBannerLoader` instead of `CookieConsentBanner` directly to prevent SSR hydration mismatches:
```tsx
// apps/web-next/src/app/layout.tsx
import { CookieConsentBannerLoader } from "@repo/core-ui/cookie-consent-banner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<CookieConsentBannerLoader />{" "}
{/* renders null on server, mounts on client */}
</body>
</html>
);
}
```
`CookieConsentBannerLoader` returns `null` during SSR and mounts the full `CookieConsentBanner` only after hydration, avoiding the `document is not defined` error and preventing layout shift from banner flicker on first load.
---
## CNIL / EDPB equal-prominence requirement
The French CNIL and the EDPB both require that the banner offers **equivalent visual weight** to accepting and rejecting consent — a prominent "Accept all" button paired with an equally-prominent "Reject all" button, with no pre-checked non-necessary categories.
The default `CookieConsentBanner` implements this:
- **Accept all** and **Reject all** buttons are rendered at the same visual prominence (identical `variant` prop).
- Non-necessary category checkboxes default to **unchecked**.
- ESC key triggers "Reject all" (keyboard-accessible rejection path).
- The `required: true` flag on a `CookieCategory` marks it as always-on and renders a disabled, checked checkbox — do not use `required: true` for non-necessary categories.
If you customise the banner via render props (`renderActions`, `renderCategoryRow`), you must preserve these guarantees manually. Failing to offer an equivalent "Reject all" path is a CNIL/EDPB violation.
---
## Cross-references
- Cookie banner component: `@repo/core-ui``CookieConsentBanner`, `CookieConsentBannerLoader`
- Anonymous migration: `@repo/core-consent/migration``extractAnonymousConsent`, `migrateAnonymousConsent`
- Conformance rule: `no-undeclared-consent-check` in `docs/guides/conformance-quickref.md`
- Glossary: `ConsentChecked`, `UserConsentState` in `docs/glossary.md`
- ADR: ADR-025 (compliance baseline channels)