Files
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

9.6 KiB

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:

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:

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

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


Inside a use case, call consent.isGranted(category) before performing the gated work:

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

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:

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


The client-side consent state is stored in the __consent_state cookie as JSON:

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:

// 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-uiCookieConsentBanner, CookieConsentBannerLoader
  • Anonymous migration: @repo/core-consent/migrationextractAnonymousConsent, 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)