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.

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)

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

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.

End-to-end example

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

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

Where to go next

On this page