Skip to content
pleach
Compliance

Compliance · GDPR right-to-erasure

executeErasureRequest — the structural primitive for Article 17 erasure against an append-only hash chain. Reads the fundamental tension honestly; surfaces both approaches.

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

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.

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.

Status (2026-08-13): SHIPPED — this is the production path. The line previously here said crypto-shredding was not shipped and deferred it to @pleach/compliance-pii. That is stale: executeErasureRequest implements approach (a) today. It calls subjectKeys.shred(tenantId, subjectId), seals a KMS-signed shred proof, and deliberately never rebuilds the chain (chainRebuilt: false). Per the module docstring: "Erasure destroys the DEK — nothing else. The ciphertext stays in place, every row_hash/prev_hash keeps verifying byte-for-byte."

What approach (a) still asks of YOU is the KEK infrastructure named in the Cons above — the package stays vendor-neutral by not shipping a concrete key registry, so you supply subjectKeys. See the end-to-end example.

Covered by packages/compliance/src/hashchain/__tests__/erasurePhase4.test.ts: "shreds S, seals a verifiable KMS proof, erases S's payload, keeps T intact, and never rebuilds" + an idempotency case.

Approach (b) — Row deletion + chain rebuild (NOT the shipped 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 + pre-erasure verifiable attestations that stay verifiable should prefer crypto-shredding (approach (a)).

executeErasureRequest — the structural primitive

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

ScopeCoverage
"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_types 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

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

  • 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

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.

What you must supply

executeErasureRequest takes two injected dependencies. Both are required, and they come from different packages — verified against the built artifacts 2026-08-13:

dependencytype you implementwhere the type livesreference impl
subjectKeysSubjectKeyRegistryLike✅ @pleach/compliance/hashchain❌ none ships — see below
attestationKeyStoreAttestationKeyStore⚠️ not re-exported by @pleach/compliance; import from @pleach/core/attestation✅ FileBackedAttestationKeyStore from @pleach/core/attestation/testing

subjectKeys has no reference implementation, and that is deliberate. The package stays vendor-neutral by shipping the seam, not key custody — your DEKs live in your KMS, not in ours. The surface is small enough to implement inline:

import type { SubjectKeyRegistryLike } from "@pleach/compliance/hashchain";

const subjectKeys: SubjectKeyRegistryLike = {
  // MUST be idempotent — a repeated DSAR for the same subject must not throw.
  async shred(tenantId, subjectId) {
    await kms.scheduleKeyDeletion(`${tenantId}/${subjectId}`);
  },
};

For local development / tests, @pleach/core/attestation/testing ships a file-backed signer:

import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing";
// ⚠ expects a raw 32-byte ed25519 SEED on disk — NOT a PEM file.
//   crypto.randomBytes(32) written to `privateKeyPath` is what it wants.
const attestationKeyStore = new FileBackedAttestationKeyStore({ privateKeyPath });

Use a KMS-backed AttestationKeyStore in production — the file-backed one is a development affordance, and the shred proof is only as trustworthy as the key that signed it.

End-to-end example

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

/**
 * `subjectKeys` is the BYO plug-point. `executeErasureRequest` destroys the
 * subject's DEK through it — it never touches the chain — so you supply whatever
 * key custody your deployment already uses (cloud KMS, HSM, Vault). The package
 * stays vendor-neutral by NOT shipping a concrete registry.
 */
const subjectKeys = {
  async shred(tenantId: string, subjectId: string): Promise<void> {
    await kms.destroyKey(`${tenantId}/${subjectId}`); // idempotent
  },
};

async function handleDsarRequest(
  tenantId: string,
  subjectEmail: string,
  caseId: string,
  operatorEmail: string,
) {
  const result = await executeErasureRequest({
    request: {
      tenantId,
      subjectId: subjectEmail,
      reason: `${caseId} Art 17(1)(a)`,
      operator: operatorEmail,
    },
    subjectKeys,
    attestationKeyStore, // your KMS-backed AttestationKeyStore
  });

  // result.shredded     === true   — the DEK is destroyed
  // result.chainRebuilt === false  — the chain was NOT touched, and still verifies
  // result.attestation             — KMS-signed shred proof; verifyErasureAttestation()

  await persistAttestation(pool, { caseId, erasureResult: result });
  return result;
}

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