# GDPR and Malaysia PDPA

**Status: engineering controls map — not a certification, not legal advice.**  
EdgeCMS implements concrete privacy hooks (inventory, export/delete, retention, consent recording, residency notice). Counsel must still review the draft legal pack under `apps/website/legal/` before any customer-facing claim.

Companion code: `apps/api/src/privacy/`. Machine inventory: `GET /api/privacy/inventory`. Notice: `GET /api/privacy/notice`.

## Scope (honest)

| In this slice | Out of this slice |
| --- | --- |
| Data inventory of what the Worker stores | DPIA, RoPA as a legal filing, ISO/SOC attestation |
| HS-13 user export + deletion (already shipped; extended) | Field-level PII search/redaction inside entry JSON |
| Entry version retention hooks (keep-N / max-age) | Automated workspace wipe after 30 days (HS-09 Planned) |
| SU-04 legal-acceptance **API hooks** + D1 table | Signup checkbox UI / website wiring (SU-04 Planned) |
| Declarative data-residency **notice** (env / host policy) | Pinning Cloudflare D1/R2 to a single country |
| Audit retention ≥365 days (HS-12) | Erasing host SIEM copies, Cloudflare logs, backups |
| Link to draft Privacy Policy / DPA / subprocessors | Filling `[COMPANY]` placeholders or publishing the drafts |

Malaysia PDPA 2010 (as amended) and GDPR share notice, purpose limitation, access/correction, and deletion themes. Where PDPA requires a specific consent form, the draft Privacy Policy is the notice and `legal_acceptances` records the tick — once signup wires `SU-04`.

## Roles

| Role | When | Document |
| --- | --- | --- |
| **Controller** | Account, sessions, memberships, billing contact, website, audit of platform actions | [Privacy Policy](../../apps/website/legal/privacy-policy.md) |
| **Processor** | Personal data customers put in entries, assets, AI context | [Data Processing Addendum](../../apps/website/legal/data-processing-addendum.md) |

Customers remain the controller for content they store. EdgeCMS does not invent a legal basis for that content; the customer does.

## Data inventory

Authoritative list lives in code (`PRIVACY_DATA_INVENTORY`) and is served at `GET /api/privacy/inventory`. Summary:

| Category | Role | Retention | Deletion coverage |
| --- | --- | --- | --- |
| Account profile | Controller | Lifecycle of account | HS-13 user deletion |
| Credentials / MFA | Controller | Lifecycle | HS-13 (export omits secrets) |
| Sessions (incl. IP / UA) | Controller | Until expiry or deletion | HS-13 |
| Memberships | Controller | Lifecycle | HS-13 (sole-owner blocked) |
| Legal acceptances | Controller | Lifecycle (proof of notice) | HS-13 |
| Authored-by metadata | Processor | Follows content row | Pseudonymized on HS-13 |
| Audit events | Controller | ≥365 days (`AUDIT_RETENTION_DAYS`) | Pseudonymized; pruned by retention |
| Entry versions | Processor | Max 50 / 90 days | Content retention; **not** user wipe |
| Assets / live entries | Processor | Lifecycle / HS-09 target | Tenant offboarding **Planned** |
| AI context to subprocessors | Processor | Provider terms; AI off until enabled | Out of HS-13 scope beyond `created_by` |
| Edge / SIEM / backups | Controller | Host / Cloudflare policy | Out of scope |

### Limits that stay true

- Personal data typed into **content fields** is not searched or changed on user deletion.
- Rows in a **dedicated tenant D1** are not pseudonymized by HS-13.
- Webhook payloads, operator logs and backups outside D1 are host-owned.
- Pseudonymized ≠ anonymized: someone holding the old user id can still match audit digests.

## Rights → product hooks

| Right (GDPR Art. / PDPA theme) | Hook |
| --- | --- |
| Access / portability | `GET /api/me/data-export` (and admin twin); Settings → Account → Your data |
| Erasure | `POST /api/me/data-deletion` `{ "confirm": "DELETE" }` — pseudonymize refs, delete account |
| Notice | Draft Privacy Policy; `GET /api/privacy/notice`; inventory endpoint |
| Consent / acceptance record | `POST /api/me/legal-acceptance` `{ document, version }` (SU-04 hook) |
| Restrict / object | Manual / support process today — no product toggle |
| Breach notice | Promised in draft DPA; operational runbook is host-owned |

Details of export/delete behaviour: [Operations and security — Personal data requests](operations-security.md#personal-data-requests).

## Retention

Central knobs: `apps/api/src/privacy/retention-policy.ts` (`PRIVACY_RETENTION`).

| Data | Enforced? | Value |
| --- | --- | --- |
| Audit ledger | Yes | Min/default **365** days |
| Entry versions | Yes | Max **50** per entry; drop older than **90** days (never current) |
| Account / workspace after closure | **No** (documented target) | **30** days then delete — needs HS-09 |
| Billing / tax | Host counsel | Placeholder in Privacy Policy |

Content hook used by write paths: `contentRetentionHooks.enforceEntryVersions` (wraps `retentionService`).

## Consent and legal documents

Drafts (not in force): `apps/website/legal/`.

| Document | Product hook |
| --- | --- |
| Terms of Service | `legal_acceptances.document = terms_of_service` |
| Privacy Policy | `privacy_policy` |
| DPA | `dpa` (usually B2B countersign; still recordable) |

`POST /api/me/legal-acceptance` is idempotent per `(userId, document, version)`. Signup UI that requires the checkbox before account creation is **SU-04 Planned** — do not claim it is live on the website until that ships.

## Locale and data residency

- **Content locales** (`localeCatalog`, collection `supportedLocales`, public locale provenance headers) are editorial/i18n features, not a residency control.
- **ABAC `locale` attribute** gates who may edit a locale; it does not move bytes.
- **Cloudflare** runs a global edge. D1/R2/KV locations follow the Cloudflare account configuration. This product does **not** offer a per-tenant “store only in MY” switch.
- Hosts set a customer-facing sentence via `host.privacy.dataResidencyNotice` or `PRIVACY_DATA_RESIDENCY_NOTICE`. Example: jurisdiction of the operating entity + SCC posture for EU transfers. Exposed at `GET /api/privacy/notice`.
- Cross-border transfers for EU/UK customers need SCCs (or equivalent) with subprocessors — see draft [Subprocessors](../../apps/website/legal/subprocessors.md). Malaysia PDPA transfer rules need counsel review for the operating entity.

## Host composition

```ts
createApp({
  host: {
    privacy: {
      dataResidencyNotice:
        'Operating entity in Malaysia; primary Cloudflare account region APAC; EU transfers under SCCs.',
      onPrivacyEvent: async (event) => {
        // enqueue ticket / SecOps notify — never block the request on slow I/O
        console.log(event.action, event.userId)
      },
    },
  },
})
```

`onPrivacyEvent` fires after a successful HS-13 export or deletion. Errors are logged and never fail the request.

## Related requirements

| ID | Status | Note |
| --- | --- | --- |
| HS-12 Audit retention | Shipped | ≥365 days |
| HS-13 Personal data requests | Shipped | Extended with legal acceptances + host event |
| HS-09 Tenant offboarding | Planned | Needed for post-closure content wipe |
| HS-14 AI switch | Shipped | No AI subprocessors until tenant enables |
| SU-04 Accept ToS/privacy at signup | Planned | Table + API ready; UI not |
| SP-07 Certifications | Not claimed | Keep docs free of fake cert language |

## Tests

```text
bun test --isolate \
  apps/api/src/tests/privacy/privacy.service.test.ts \
  apps/api/src/tests/privacy/privacy.controller.test.ts \
  apps/api/src/tests/privacy/data-inventory.test.ts \
  apps/api/src/tests/privacy/legal-acceptance.test.ts
```
