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>
This commit is contained in:
@@ -32,16 +32,17 @@ export type FooManifest = typeof fooManifest;
|
||||
|
||||
Field reference:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------- | -------------- | --------------------------------------------------------------------------- |
|
||||
| `name` | string literal | Feature name (kebab-case, matches package name) |
|
||||
| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) |
|
||||
| `useCases.<name>.mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required |
|
||||
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
|
||||
| `useCases.<name>.publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` |
|
||||
| `useCases.<name>.consumes` | string[] | Cross-feature events this use case consumes (via an event handler) |
|
||||
| `realtimeChannels` | string[] | Realtime channels this feature owns |
|
||||
| `jobs` | string[] | Job slugs this feature enqueues |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | string literal | Feature name (kebab-case, matches package name) |
|
||||
| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) |
|
||||
| `useCases.<name>.mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required |
|
||||
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
|
||||
| `useCases.<name>.publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` |
|
||||
| `useCases.<name>.consumes` | string[] | Cross-feature events this use case consumes (via an event handler) |
|
||||
| `realtimeChannels` | string[] | Realtime channels this feature owns |
|
||||
| `jobs` | string[] | Job slugs this feature enqueues |
|
||||
| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` |
|
||||
|
||||
Re-export from `src/index.ts`:
|
||||
|
||||
@@ -96,6 +97,9 @@ The symbol map declares which container symbol each manifest use-case key resolv
|
||||
| `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "<key>" })` in `bind-production.ts` / `bind-dev-seed.ts` |
|
||||
| `conformance/no-undeclared-analytics-event` | warn | `analytics.track("X")` literal must match the manifest's `analyticsEvents` for the use case |
|
||||
| `conformance/pii-declaration-must-be-complete` | warn | `custom.pii` blocks in Payload config files must declare all required fields: `category`, `purpose`, `exportable`, `restrictable` |
|
||||
| `conformance/component-must-have-story` | warn | Every component file under `src/` must have a sibling `.stories.tsx` file |
|
||||
| `conformance/component-must-have-test` | warn | Every component file under `src/` must have a sibling `.test.tsx` file |
|
||||
| `conformance/atomic-tier-import-direction` | warn | Atomic-design import direction must flow downward (atoms ← molecules ← organisms ← templates ← pages); no upward imports |
|
||||
| `conformance/no-undeclared-consent-check` | warn | `consent.isGranted("X")` literal in a use-case file must match a category declared in `manifest.requiresConsent`; warns if declared categories are never checked |
|
||||
|
||||
## Workflow ordering for new use cases
|
||||
|
||||
255
docs/guides/consent.md
Normal file
255
docs/guides/consent.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 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)
|
||||
189
docs/guides/dsr.md
Normal file
189
docs/guides/dsr.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Data Subject Rights (DSR) guide
|
||||
|
||||
Consumer-facing reference for the `@repo/core-dsr` optional core package. Covers the four GDPR interfaces, tRPC procedure wiring, multi-subject collection handling, deletion semantics, `DeletionCertificate` format, and per-article compliance notes.
|
||||
|
||||
**Prerequisite:** scaffold the package if it isn't already present:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-package dsr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interfaces and GDPR article mapping
|
||||
|
||||
`@repo/core-dsr` exposes four vendor-neutral interfaces:
|
||||
|
||||
| Interface | tRPC procedure | GDPR article(s) | Mutation? |
|
||||
| ------------------------ | -------------- | ----------------------------------------- | ---------- |
|
||||
| `IDataExport` | `dsr.export` | Art. 15 (access) + Art. 20 (portability) | No (query) |
|
||||
| `IDataDelete` | `dsr.delete` | Art. 17 (erasure / right to be forgotten) | Yes |
|
||||
| `IDataRectify` | `dsr.rectify` | Art. 16 (rectification) | Yes |
|
||||
| `IProcessingRestriction` | `dsr.restrict` | Art. 18 (restriction of processing) | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Wiring the DSR router
|
||||
|
||||
`core-dsr` ships a pre-built tRPC router. Mount it once in your app router:
|
||||
|
||||
```ts
|
||||
// apps/web-next/src/server/router.ts
|
||||
import { createDsrRouter } from "@repo/core-dsr";
|
||||
import { bindProductionDsr } from "@repo/core-dsr/di/bind-production";
|
||||
|
||||
const dsrBinding = bindProductionDsr({ config: payloadConfig, auditLog });
|
||||
|
||||
export const appRouter = t.router({
|
||||
// ... other feature routers
|
||||
dsr: createDsrRouter(dsrBinding),
|
||||
});
|
||||
```
|
||||
|
||||
All four procedures require an authenticated user in `ctx.user`. `cascade-hard` deletion additionally requires `ctx.user.roles` to include `"admin"`.
|
||||
|
||||
### Input schemas
|
||||
|
||||
| Procedure | Input fields |
|
||||
| -------------- | ---------------------------------------------------------------------------- |
|
||||
| `dsr.export` | `subjectId: string`, `format: "json" \| "json-ld"` |
|
||||
| `dsr.delete` | `subjectId: string`, `mode: "soft" \| "cascade-hard"` |
|
||||
| `dsr.rectify` | `subjectId: string`, `collection: string`, `field: string`, `value: unknown` |
|
||||
| `dsr.restrict` | `subjectId: string`, `granted: boolean` |
|
||||
|
||||
---
|
||||
|
||||
## Multi-subject handling
|
||||
|
||||
A Payload collection can reference **multiple data subjects** — for example, a support ticket has both a submitter and an assignee. Declare subject relationships via `custom.subject` on the collection config:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/integrations/cms/support-tickets.collection.ts
|
||||
{
|
||||
slug: "support-tickets",
|
||||
custom: {
|
||||
subject: [
|
||||
{ field: "submittedBy", kind: "self", target: "users" },
|
||||
{ field: "assignedTo", kind: "reference", target: "users", role: "assignee" },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{ name: "submittedBy", type: "relationship", relationTo: "users" },
|
||||
{ name: "assignedTo", type: "relationship", relationTo: "users" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The DSR cascade walks each `SubjectLink` at runtime to determine scope:
|
||||
|
||||
| `kind` | Meaning | Export behaviour | Delete behaviour |
|
||||
| ------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | ---------------------------------- |
|
||||
| `"self"` | The subject **is** this row (e.g., the Users row itself) | Full row in `asSelf` | Row deleted / pseudonymized |
|
||||
| `"owner"` | The subject **created or owns** this row (e.g., posts authored by the user) | Full row in `asSelf` | Row deleted / pseudonymized |
|
||||
| `"reference"` | The subject is **referenced** in this row but does not own it (e.g., assignee on a ticket) | Row ID + link coords in `asReference` | Linked field NULLed; row preserved |
|
||||
|
||||
See `docs/compliance/subject-linkage.example.md` for a full annotated example.
|
||||
|
||||
---
|
||||
|
||||
## Deletion modes
|
||||
|
||||
### `soft` (default, no admin role required)
|
||||
|
||||
Redacts or pseudonymizes PII fields in rows the subject owns (`kind: "self" | "owner"`) while preserving foreign-key integrity. Reference rows (`kind: "reference"`) have the linking field NULLed. The row structure remains intact — useful for preserving order history, audit trails, and referential integrity.
|
||||
|
||||
### `cascade-hard` (admin role required)
|
||||
|
||||
Hard-deletes all rows the subject owns (where `postDeletion.action === "hard-delete"` in the collection's retention config), then NULLs reference fields. Use only when the subject's right to erasure overrides your referential-integrity requirements or when retention policy mandates hard deletion.
|
||||
|
||||
Retention-policy overrides apply: a collection with `postDeletion.action = "pseudonymize"` will pseudonymize rather than hard-delete even in `cascade-hard` mode.
|
||||
|
||||
---
|
||||
|
||||
## `DeletionCertificate` format
|
||||
|
||||
`IDataDelete.deleteSubjectData(subjectId, mode)` resolves to a `DeletionCertificate`:
|
||||
|
||||
```ts
|
||||
type DeletionCertificate = {
|
||||
subjectId: string; // or "erased-{hash}" if the ID itself was purged
|
||||
mode: "soft" | "cascade-hard";
|
||||
timestamp: string; // ISO 8601
|
||||
reason: "art-17-request" | "admin-expunge" | "retention-policy";
|
||||
affected: Array<{
|
||||
collection: string;
|
||||
rowsAffected: number;
|
||||
action: "deleted" | "redacted" | "pseudonymized";
|
||||
fields?: string[]; // PII field names NULLed when action === "redacted"
|
||||
}>;
|
||||
auditEntryId: string; // links to the immutable audit log entry
|
||||
};
|
||||
```
|
||||
|
||||
**Storage requirements:** persist the `DeletionCertificate` permanently — it is your Art. 17 compliance evidence for regulatory inspection. The `auditEntryId` forms a tamper-evident chain back to the `core-audit` log. Never mutate a certificate after creation.
|
||||
|
||||
---
|
||||
|
||||
## `UserDataBundle` format (export)
|
||||
|
||||
`IDataExport.exportSubjectData(subjectId, format)` resolves to a `UserDataBundle`:
|
||||
|
||||
```ts
|
||||
type UserDataBundle = {
|
||||
subjectId: string;
|
||||
exportedAt: string; // ISO 8601
|
||||
format: "json" | "json-ld";
|
||||
data: Record<
|
||||
string,
|
||||
{
|
||||
// keyed by collection slug
|
||||
asSelf?: Array<Record<string, unknown>>; // owned rows (PII-filtered)
|
||||
asReference?: Array<{
|
||||
rowId: string;
|
||||
linkedField: string;
|
||||
linkedThrough: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
auditLog?: AuditEntry[]; // subject-scoped audit history
|
||||
"@context"?: string | Record<string, unknown>; // JSON-LD only
|
||||
};
|
||||
```
|
||||
|
||||
Only fields marked `exportable: true` in their `custom.pii` block are included in `asSelf` rows.
|
||||
|
||||
---
|
||||
|
||||
## Compliance notes
|
||||
|
||||
### Art. 15 — Right of access
|
||||
|
||||
`dsr.export` with `format: "json"` satisfies the right of access. Return the `UserDataBundle` directly in the API response or as a downloadable JSON file. Response time: ≤ 30 days under GDPR.
|
||||
|
||||
### Art. 16 — Right to rectification
|
||||
|
||||
`dsr.rectify` targets a single field. For bulk updates, call it once per field. The implementation calls `IDataRectify.rectifySubjectData(subjectId, collection, field, value)` and records an audit entry.
|
||||
|
||||
### Art. 17 — Right to erasure
|
||||
|
||||
`dsr.delete` with `mode: "soft"` is the default erasure path. Use `mode: "cascade-hard"` only when data minimisation obligations outweigh referential integrity needs. Both paths produce a `DeletionCertificate`.
|
||||
|
||||
**Exemptions:** Art. 17(3) allows retention for legal obligations (e.g., invoices, tax records). Implement exemptions via a `postDeletion.action = "pseudonymize"` retention override on the affected collection — the DSR cascade respects this automatically.
|
||||
|
||||
### Art. 18 — Restriction of processing
|
||||
|
||||
`dsr.restrict` with `granted: true` flags the subject's data for restricted processing. Your application code must check `IProcessingRestriction.isRestricted(subjectId)` before performing processing operations on restricted subjects.
|
||||
|
||||
### Art. 20 — Right to data portability
|
||||
|
||||
`dsr.export` with `format: "json-ld"` produces a machine-readable JSON-LD export suitable for portability. The `@context` field is populated with the schema URI for downstream consumption.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Subject linkage patterns: `docs/compliance/subject-linkage.example.md`
|
||||
- PII field tagging: `docs/compliance/README.md` → "Annotating PII fields"
|
||||
- Retention policy: `docs/compliance/retention-policy.example.yml`
|
||||
- Audit log: `docs/guides/audit-and-compliance.md`
|
||||
- Glossary: `SubjectLink`, `DeletionCertificate` in `docs/glossary.md`
|
||||
Reference in New Issue
Block a user