# Compliance · GDPR right-to-erasure (/docs/compliance/gdpr-erasure)



GDPR Article 17 ("right to be forgotten") obligates the controller to
erase personal data on a qualifying request. Append-only hash chains
— the substrate that gives `harness_event_log` cryptographic
tamper-evidence — are **fundamentally at odds** with row-level deletion.

This page explains the tension, the two production approaches, and
the structural primitive `@pleach/compliance` ships for one of them.

Shipped in an earlier release.

## The fundamental tension [#the-fundamental-tension]

A hash chain's integrity depends on the **exact bytes** of every row
participating in the next row's `row_hash`. Erase one row in the
middle of the chain and every downstream row's `prev_hash` no longer
matches the prior row's `row_hash` — the chain breaks for the entire
suffix.

A naive `DELETE` on a PII row, then, is structurally indistinguishable
from substrate tamper. Any honest GDPR-compliant deployment of a hash
chain has to choose between two reconciliation strategies, neither of
which is free.

Procurement conversations should NOT skip this distinction.

## Approach (a) — Crypto-shredding (recommended for production) [#approach-a--crypto-shredding-recommended-for-production]

PII fields are stored ENCRYPTED at write time with a per-subject key
(typically AES-GCM with a per-subject KEK in a managed KMS). Erasure
deletes the per-subject key — the encrypted blob remains in place,
the row hash is unchanged, the chain stays valid, and the data is
cryptographically inaccessible.

**Pros:** Chain integrity preserved indefinitely; pre-erasure
attestations continue to verify; matches the deployment model auditors
expect for highly regulated workloads.

**Cons:** Requires upstream KEK management infrastructure
(`@aws-sdk/client-kms`, GCP KMS, Azure Key Vault) and per-field
envelope encryption discipline at every write site. Higher onboarding
cost.

This SKU does **not** ship crypto-shredding today. AEAD field encryption + OOB key shred lives in a future release or in `@pleach/compliance-pii` once the AEAD envelope contract is locked.

## Approach (b) — Row deletion + chain rebuild (this slice's primitive) [#approach-b--row-deletion--chain-rebuild-this-slices-primitive]

PII rows are physically deleted; downstream rows are re-anchored
against a fresh genesis seed. The rebuilt chain has a DIFFERENT
genesis root than the pre-erasure chain.

**Pros:** No upstream KEK infrastructure required; works against any
event store with a `DELETE` capability.

**Cons:** Pre-erasure attestations no longer verify against the
post-erasure chain. **This is a feature, not a bug** — the genesis-root
change IS the cryptographic proof of erasure. A verifier comparing
pre-erasure and post-erasure roots sees the divergence and concludes
"erasure happened here."

Operator MUST accept this trade-off explicitly. Any deployment that
needs both Article 17 + immutable pre-erasure verifiable attestations
should prefer crypto-shredding (approach (a)).

## `executeErasureRequest` — the structural primitive [#executeerasurerequest--the-structural-primitive]

```ts
import { executeErasureRequest } from "@pleach/compliance/hashchain";

const result = await executeErasureRequest({
  request: {
    tenantId: "tenant-acme",
    subjectId: "user@example.com",
    scope: "all",
    reason: "DSAR-2026-06-15 Art 17(1)(a)",
    operator: "support-engineer@example.com",
  },
  eventStore: {
    async eraseRows(filter) {
      return await pgEraseRowsForSubject(pool, filter);
    },
  },
  rebuildChainFromGenesis: async (tenantId, chatId) => {
    return await rebuildChainInPostgres(pool, tenantId, chatId);
  },
  chatIds: ["chat-1", "chat-2"], // optional; defaults to [subjectId]
});

console.log(result);
// {
//   rowsErased: 142,
//   chainValidAfter: true,
//   attestation: {
//     timestamp: "2026-06-15T10:42:01.123Z",
//     reason: "DSAR-2026-06-15 Art 17(1)(a)",
//     operator: "support-engineer@example.com",
//   },
// }
```

### `ErasureScope` [#erasurescope]

| Scope        | Coverage                                                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------------- |
| `"messages"` | Every row tied to user-authored messages (`content.delta`, `content.finalized` payloads carrying user prose). |
| `"calls"`    | Every row tied to LLM call traces (`llm.turn`, `tool.invoked`).                                               |
| `"all"`      | Every event-log row for the subject within the tenant.                                                        |

The mapping from scope to a concrete filter is the **adapter's
responsibility**. `ErasureEventStoreLike.eraseRows` receives the scope
along with `tenantId` + `subjectId` and decides which `event_type`s
participate. This keeps `@pleach/compliance` vendor-neutral — the
package has no opinion about your event-type taxonomy.

### Injected callbacks — why the package stays vendor-neutral [#injected-callbacks--why-the-package-stays-vendor-neutral]

Both `eventStore.eraseRows` and `rebuildChainFromGenesis` are
consumer-supplied structural callbacks. The actual erasure is a
database operation (Postgres `DELETE`, Supabase RLS-scoped soft-delete,
or per-row tombstone); the chain rebuild is a substrate-specific
recompute that walks every downstream row and re-canonicalizes it.

Neither is portable across substrates, so neither belongs in the
package. The structural primitive sequences the two operations,
aggregates per-chat rebuild verdicts into the final `chainValidAfter`
flag, and seals an attestation footer.

### Soft-fail vs throw [#soft-fail-vs-throw]

* **Adapter error on `eraseRows`** — soft-failed. The helper returns
  `rowsErased: 0`, `chainValidAfter: false`, and an attestation tagged
  `<erasure-failed-soft>`. Chain integrity breaks are the only throw case.
* **Rebuild callback throws** — soft-failed per chat. The helper sets
  `chainValidAfter: false` and continues to the next chat.
* **`ChainVerificationError` from either callback** — re-thrown.
  Chain integrity breaks are hard failures by design.

## What the attestation proves [#what-the-attestation-proves]

```ts
interface ErasureAttestation {
  readonly timestamp: string; // ISO-8601 seal time
  readonly reason: string;     // operator-supplied audit reason
  readonly operator?: string;  // operator id
}
```

The attestation is the audit footer the operator presents downstream:
"erasure executed at `timestamp`, scoped to `request.scope`, under
lawful basis `request.reason`, by `operator`." Append-only — the
helper does not retain the attestation; it is the operator's
responsibility to persist it in the compliance ledger.

The attestation is INTENTIONALLY minimal. It does NOT carry the new
chain root or the count of rows erased — those are returned alongside
in `ErasureResult` and persisted separately by the operator. A future
slice may extend the attestation footer with a chain-root pointer; for
now the operator wires the cross-reference.

## End-to-end example [#end-to-end-example]

```ts
import { Pool } from "pg";
import { executeErasureRequest } from "@pleach/compliance/hashchain";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function handleDsarRequest(
  tenantId: string,
  subjectEmail: string,
  caseId: string,
  operatorEmail: string,
) {
  // 1. Resolve which chats the subject participated in. Adapter-level
  //    decision — typically a join against a per-tenant subject->chat
  //    table.
  const chatIds = await resolveSubjectChats(pool, tenantId, subjectEmail);

  // 2. Execute the erasure + chain rebuild.
  const result = await executeErasureRequest({
    request: {
      tenantId,
      subjectId: subjectEmail,
      scope: "all",
      reason: `${caseId} Art 17(1)(a)`,
      operator: operatorEmail,
    },
    eventStore: {
      eraseRows: async (filter) => pgEraseSubjectRows(pool, filter),
    },
    rebuildChainFromGenesis: async (t, c) =>
      pgRebuildChainFromGenesis(pool, t, c),
    chatIds,
  });

  // 3. Persist the attestation footer in the compliance ledger.
  await persistAttestation(pool, {
    caseId,
    erasureResult: result,
  });

  return result;
}
```

## Cited source [#cited-source]

* `packages/compliance/src/hashchain/erasure.ts` —
  `executeErasureRequest` factory.
* `packages/compliance/src/hashchain/index.ts` — barrel re-export.
* `packages/compliance/test/gdprErasure.smoke.test.mjs` —
  surface regression-lock.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="HashChainMiddleware" href="/docs/compliance/hashchain-middleware" description="The companion helper that decorates each event with chain anchors before persistence." />

  <Card title="Hash chain" href="/docs/hash-chain" description="Substrate-level walkthrough of @pleach/core's chain primitives." />

  <Card title="Compliance" href="/docs/compliance" description="The full @pleach/compliance surface — redaction, scrubbers, audit query, PDF export." />

  <Card title="Regulated domain agent" href="/docs/regulated-domain-agent" description="End-to-end recipe composing compliance + hash chain + erasure." />
</Cards>
