GDPR erasure¶
Architecture
Normative spec
This page documents .ai/specs/core-engine/2026-07-02-gdpr-erasure.md (Status: approved for build, engineering re-review 2026-07-06; legal sign-off remains outstanding and gates GA, not the build) and the core-engine/src/erasure/ implementation (the CORE leg of PR-081). The app-layer leg, the BullMQ PostgreSQL erasure worker, subject-scoped DEK shred, and the subject index, is a separate follow-up; its core surfaces (the stage/pg report-back endpoint and the Tier-1 ordering contract) are defined in this spec and built here.
Telha's history is append-only by design, but Article 17 is not optional: this page explains the two mechanisms Telha uses to physically destroy personal data on request, and how that coexists with a store that otherwise never deletes anything.
Overview¶
GDPR erasure defines two classes of request:
| Class | Scope | Trigger |
|---|---|---|
| Tenant-level | An entire tenant's data | Offboarding |
| Subject-level | One person's data across the graph | Right-to-be-Forgotten (Article 17) |
Both classes run through the same staged orchestrator (ErasureOps), write to the same JSONL erasure ledger, and are the one sanctioned violation of the append-only doctrine: bounded, ledgered, and legally compelled, not a silent exception.
This is the one place Telha deletes history
Everywhere else in the product, corrections and deletions are new versions or tombstones layered on top of an intact past (see The tritemporal model). Erasure is different: it is explicitly extra-temporal physical deletion, gated behind dual approval, admin-only auth, and a two-pass verification scan whose report is the completion proof.
Design¶
Two mechanisms, and when each applies¶
Crypto-shredding (Tier 1, immediate). Classified fields are already encrypted per-tenant at the application layer before core ever sees them (see Field encryption and Tenancy & security). Subject-level erasure extends this with subject-scoped DEKs: an HKDF derivation from the tenant DEK plus the subject id. Destroying the derivation record for a subject renders every classified field keyed to that subject permanently unreadable, instantly, including in backups. This is Tier 1 because it requires no row-by-row physical work: the ciphertext survives everywhere, but nothing can ever decrypt it again. Core's erasure stage machinery records the ordering contract (Tier 1 must run before any physical stage); the actual field_crypto_deks destruction is the app-layer leg's action.
Tombstoning and masking (Tier 2, asynchronous physical deletion). Everything crypto-shredding cannot reach, unclassified plaintext fields, canonical source text, spans, vectors, traces, job payloads, is handled by the staged executor pipeline described below: subject nodes are physically purged down to a sanitized terminal tombstone; canonical text is masked in place; derived data (vectors, bitmap postings, HNSW partitions) is deleted and rebuilt.
Why not crypto-shredding for everything
The spec's alternatives analysis (§9) rejects pure crypto-shredding as the whole answer: unclassified plaintext fields exist by design (not everything is classified), so shredding only covers what was encrypted in the first place. Tier 2 exists precisely for what Tier 1 cannot reach.
Reconciling "never delete history" with "must erase on request"¶
The append-only doctrine and Article 17 are reconciled by treating erasure as a distinct, narrow, audited exception rather than bending the temporal model to accommodate it:
- Erasure runs through its own orchestrator, not the ordinary node/edge write path. A tombstoned subject node keeps its pseudonymous logical id (so a future re-ingestion upsert against that id is skipped, per the ingestion-provenance spec's dedup rule) but every prior version and every property value is physically gone; the sole survivor is a sanitized terminal tombstone with empty properties.
- Canonical text blobs are masked, not spliced: every byte in a redaction range is overwritten with the mask byte
0x2A, same length, so every other entity's span byte-offsets stay valid. The blob keeps its originalblob_idas an addressing key; a blob redaction record becomes the new integrity anchor (post_redaction_hash) from that point forward. - The erasure ledger lives outside the erased scope entirely (
{data_dir}/erasure-ledger.jsonl), and contains no subject plaintext, only coordinates and digests. What proves erasure happened is not the ledger's prose, it's the two-pass verification scan. - Retention policy and erasure share the same executor and ledger (
EntityEraser/execute_erasure_at, built for the retention-archival spec and extended, never redesigned, for erasure). The ledger'smethodfield is the discriminator between a retention-driven deletion and a GDPR one. Precedence is: erasure outranks legal hold; legal hold outranks retention policy.
flowchart TB
subgraph history["Append-only history (everywhere else)"]
V1["Version 1"] --> V2["Version 2 (correction)"]
V2 --> V3["Tombstone (ordinary delete)"]
end
subgraph erasure["Erasure (this page)"]
SUBJ["Subject node: ALL versions"] --> PURGE["Physically purged"]
PURGE --> TOMB["Sanitized terminal tombstone<br/>(pseudonymous id kept, properties empty)"]
BLOB["Canonical blob"] --> MASK["Bytes masked in place (0x2A)<br/>same address, new post_redaction_hash"]
end
LEDGER["Erasure ledger (outside erased scope)<br/>coordinates + digests only, no plaintext"]
erasure --> LEDGER The operator surface¶
Erasure is operator/admin-only: it lives behind a signed bearer token with audience "admin" (the same admin-token boundary as /admin/retention/*), entirely separate from tenant API keys, and it is never exposed over MCP. There is no tenant self-service erasure endpoint.
Both a REST surface and a CLI mirror the same eight-stage lifecycle:
| REST route | CLI equivalent | Purpose |
|---|---|---|
POST /admin/erasure | telha erase request | Create a request (requester is approval #1) |
POST /admin/erasure/:id/approve | telha erase approve | Record the second, distinct approving principal |
GET /admin/erasure/:id/preview | telha erase preview | Stage-0 blast radius: per-identifier match counts, never writes |
POST /admin/erasure/:id/execute | telha erase execute | Run every remaining stage (resumable at stage boundaries) |
POST /admin/erasure/:id/verify | telha erase verify | Run (or resume) the verification scan + close |
GET /admin/erasure/:id | telha erase status | Staged status: state, approvals, stages |
GET /admin/erasure/:id/report | telha erase report | The completion report (the DPO's artifact) |
POST /admin/erasure/:id/stage/pg | (app-worker only) | App-layer PG stage report-back |
POST /admin/erasure/:id/residuals | telha erase review | Record a dual-approved residual review |
Creating a request:
POST /admin/erasure
{
"kind": "subject",
"descriptor": {
"personIds": ["b3f5c1a0-..."],
"identifiers": [
{ "value": "alice@example.com", "strength": "strong" },
{ "value": "Alice Smith", "strength": "weak" }
],
"wholeSources": []
},
"legalRef": "DSR-2026-0142",
"requestedBy": "dpo@tenant.example",
"deletionFallback": false
}
The response never echoes the descriptor plaintext back; it returns state and a digest:
{
"id": "6f0e...",
"kind": "subject",
"state": "requested",
"legalRef": "DSR-2026-0142",
"requestedBy": "dpo@tenant.example",
"approvals": [{ "principal": "dpo@tenant.example", "atUs": 1751500000000000 }],
"descriptorDigest": "9a3c...",
"stages": [],
"residualReviews": 0,
"createdAt": 1751500000000000
}
Nothing past preview runs before a SECOND, distinct principal approves
approve_at rejects a second approval from the same principal ("{principal} already approved; dual approval needs a SECOND principal"), and execute_at refuses to run any stage while the request is still in Requested state. This is dual-control by construction, not by convention.
Guarantees the operator gets back: a stages list recording every completed stage with its ledger entry ids, a completion report (GET /admin/erasure/:id/report) once the request reaches Completed, and every stage transition durably ledgered so a crashed job resumes at its stage boundary rather than restarting or silently losing progress.
Limits and async behavior: execution is staged and resumable, not a single synchronous call. A deployment with an app-layer PostgreSQL worker parks in AwaitingPg after the core stages finish, until that worker posts /admin/erasure/:id/stage/pg; a core-only deployment records the PG stage as not_applicable with a loud warning instead. The verification loop caps at MAX_VERIFY_PASSES (5); if residual hits keep reappearing past that, the request returns ERASURE_VERIFICATION_OVERFLOW (HTTP 409) and pages the operator instead of looping silently.
Data model¶
The request record (ErasureRequest, core-only mirror of the app-layer PG request row; holds descriptor plaintext outside every column family):
pub struct ErasureRequest {
pub v: u32,
pub id: Uuid,
pub kind: ErasureKind, // Tenant | Subject
pub tenant_id: Uuid,
pub organization_id: Uuid,
pub descriptor: SubjectDescriptor,
pub legal_ref: String,
pub requested_by: String,
pub approvals: Vec<Approval>,
pub state: RequestState,
pub deletion_fallback: bool, // OQ-1 executor policy flag
pub created_at: Ts,
pub stages: Vec<StageRecord>,
pub pg_report: Option<PgStageReport>,
pub residual_reviews: Vec<ResidualReview>,
pub scan_report: Option<ScanReport>,
pub completion: Option<CompletionReport>,
}
The subject descriptor is exactly what identifies who is being erased:
pub struct SubjectDescriptor {
pub person_ids: Vec<Uuid>, // PERSON logical ids, ALIAS_OF included
pub identifiers: Vec<IdentifierString>,
pub whole_sources: Vec<Uuid>, // deletion path, not masking
}
pub struct IdentifierString {
pub value: String,
pub strength: IdentifierStrength, // Strong | Weak
}
Identifier strength is a closure gate, not a label. Strong covers emails, phone numbers, government/employee/customer ids, AAD object ids, account numbers, and erased-content digests: closure is absolute zero, with no waiver mechanism at all. Weak covers common names and name fragments: a residual hit on a weak identifier may be closed by a dual-approved review, recorded as its own ledger entry.
Request lifecycle states (RequestState): Requested, Approved, Executing, AwaitingPg, Verifying, Completed, or Failed { stage, error } (resumable at the failed stage boundary).
The ledger record (adopts the retention-archival spec's as-built shape, additive fields for erasure):
// Ledger entry `method` values this spec adds:
// gdpr_tenant | gdpr_subject | gdpr_subject_pg
// | gdpr_blocklist_release | gdpr_waiver | gdpr_residual_review
Every GDPR ledger entry carries request_ref, request_digest (BLAKE3 of the descriptor; the plaintext itself never leaves the app-side request record), program_ref + program_hash, and retired_digests. The ledger and the programs it references are content-free by construction: the verification scan sweeps them as one of its own haystacks to prove it.
The blob redaction record (sources CF, keyed at the reserved chunk index u64::MAX):
pub struct RedactionRecord {
pub v: u32,
pub ledger_entry_ids: Vec<Uuid>,
pub ranges: Vec<(u64, u64)>, // masked byte ranges, canonical
pub post_redaction_hash: String, // BLAKE3 hex of the masked text
pub original_hash_retired: bool,
pub deleted: bool, // whole-blob deletion tombstone
}
The blocklist row (sources CF, tenant-scoped, keyed by a keyed digest, never the raw content hash):
pub struct BlocklistRow {
pub v: u32,
pub blocklist_id: Uuid,
pub digest_scheme: String, // "hmac_blake3_v1"
pub digest: String, // hex; blake3::keyed_hash(tenant_secret, raw_digest)
pub kind: DigestKind, // Content | Canonical
pub created_by_erasure_id: Uuid,
pub created_at: Ts,
pub purpose: String, // "prevent_reingest_resurrection"
pub release_supported: bool, // always false in v1
}
The completion report (CompletionReport) is the DPO's artifact: it aggregates rows_deleted per column family, blobs_masked / blobs_deleted, blocklist_rows, api_keys_revoked, clarify_rewritten / clarify_prior_versions_purged, partitions_rebuilt, superseded_manifests, the pg stage outcome, scan_passes, strong_hits_open (must be zero), weak_hits_reviewed, residual_reviews, a backup_horizon_us, a log_attestation block, and a free-form warnings list (e.g. the running-server key-reload caveat).
The erasure program (JSONL, one op per line, at {data_dir}/erasure-programs/{entry_id}.jsonl) is the coordinate-only replay format shared by execute-time application and restore-time replay:
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ProgramOp {
DeleteRange { cf: String, start: String, end: String },
Delete { cf: String, key: String },
MaskValue { cf: String, key: String, ranges: Vec<(u64, u64)> },
MaskBlob { blob_id: Uuid, ranges: Vec<(u64, u64)> },
DeleteBlob { blob_id: Uuid },
Blocklist { keyed_digest: String, kind: DigestKind },
TombstoneNode { node_id: Uuid, labels: Vec<String>, valid_start: Ts, tx_start: Ts },
ScrubSpanLinks { key: String, remove: Vec<Uuid> },
JobDelete { job_id: Uuid },
JobScrub { job_id: Uuid, payload_ranges: Vec<(u64, u64)>, nil_ranking: Vec<usize>,
event_masks: Vec<(usize, Vec<(u64, u64)>)> },
RevokeApiKey { key_hash: String },
DeleteVectorTree { tenant_id: Uuid },
}
Every key and range in a program is hex-encoded specifically so the program files stay scannable and content-free: the verification scan's pass 2 sweeps the program directory itself as a haystack.
Personal-data surface register¶
The spec's §5 register is the normative, exhaustive list of every place subject data can live; new personal-data surfaces must extend it in the same change that introduces them. A representative slice:
| Surface | How erased |
|---|---|
node_versions (incl. clarification nodes) | Subject nodes: purge all versions, sanitized terminal tombstone kept; others: in-place value masking |
sources CF: blob chunks | In-place same-length masking + redaction record, or whole-blob deletion |
spans CF | Subject-only spans deleted; shared spans: subject id scrubbed from linked_nodes |
traces CF | Identifier masking in request text, draft, claim text; skeleton preserved |
jobs CF | Terminal rows with erased content deleted outright; retained rows get payload/ranking/event masking |
vectors CF + HNSW files | Deleted; affected partitions rebuilt (refreshes .ids sidecars and manifests) |
api_keys.json | Person-bound keys revoked |
| Retention archive slices | Rewritten, re-hashed, re-manifested with supersedes, original destroyed only after the rewrite verifies |
PG auth_sessions, ai_tool_invocations, orders, etc. | App-layer erasure worker, per-table register (§3.10) |
Algorithms & invariants¶
The eight-stage pipeline¶
sequenceDiagram
autonumber
participant Op as Operator
participant API as /admin/erasure
participant Ex as ErasureOps
participant Ledger as Erasure ledger
Op->>API: POST /admin/erasure (descriptor, legal_ref)
API->>Ex: request_subject_at / request_tenant_at
Ex->>Ledger: (no write yet, Requested state)
Op->>API: POST /admin/erasure/:id/approve (2nd principal)
API->>Ex: approve_at
Note over Ex: state to Approved (>= 2 distinct approvers)
Op->>API: GET /admin/erasure/:id/preview
Ex-->>Op: per-identifier match counts per surface (no writes)
Op->>API: POST /admin/erasure/:id/execute
Ex->>Ledger: stage 1 shred (Tier-1 DEK ordering contract)
Ex->>Ledger: stage 2 freeze (cancel in-flight jobs)
Ex->>Ledger: stage 3 enumerate to program (ledgered before any row touched)
Ex->>Ex: stage 4 execute (shared executor applies the program)
Ex->>Ledger: stage 5 archives (rewrite-verify-destroy superseded slices)
alt app-layer PG expected
Ex-->>Op: parks in AwaitingPg
Op->>API: POST /admin/erasure/:id/stage/pg (worker report)
else core-only
Ex->>Ledger: stage 6 pg (not_applicable, loud warning)
end
Ex->>Ex: stage 7 verify (pass 1 structural + pass 2 byte scan, loop)
Ex->>Ledger: stage 8 close (completion report)
Op->>API: GET /admin/erasure/:id/report Verification: proof, not assumption¶
Completion is a two-pass scan, not a claim:
- Pass 1 (structural): every coordinate in every program this request wrote verifies against current state, keys are absent, ranges are masked, blob redaction records exist with matching
post_redaction_hash, blocklist rows exist, api-key bindings are gone. Tenant erasure additionally requires every column family's tenant prefix to scan empty and the vector tree to be gone. - Pass 2 (byte scan): one streaming Aho-Corasick sweep of
identifier_strings(NFC-normalized, case-folded) across all 13 column families within the tenant prefix, HNSW.idssidecars and manifests,api_keys.json, and the erasure programs and ledger themselves. Every hit becomes a masking work item and the scan re-runs, up toMAX_VERIFY_PASSES(5) before it pages the operator instead of looping.
Strong identifiers: absolute zero, no exceptions
The closure gate is absolute zero for strong identifiers and erased-content digests. There is no waiver, no review, no "acceptable residual" path for a strong hit. record_residual_review_at refuses structurally if the reviewed identifier's strength is Strong. Only a weak-identifier hit may be closed by a dual-approved review with two distinct principals, and even then, the review must not retain confirmed subject data absent a counsel-approved Article 17(3) basis.
Invariants enforced¶
Once destroyed, key material cannot be recovered
Subject-scoped DEK destruction (Tier 1) is deliberate and irreversible: the derivation record, once gone, cannot be reconstructed, so the classified fields it protected become permanently unreadable. This is the whole point: it is what makes Tier 1 an instant erasure guarantee rather than a promise about future physical work.
Every program key stays inside its tenant scope
Program application defense-in-depth: check_scope verifies every exact key carries the acting tenant's 32-byte prefix before any delete or mask runs, and range_from_hex verifies a delete-range's bounds stay inside that same prefix. A hostile or corrupted program file cannot reach across tenants even if it were replayed against the wrong scope.
Masking never touches ciphertext
Field-encryption envelopes (magic bytes 0x7E 0x1A) are excluded from both masking and the verification scan: they are unreadable ciphertext after a Tier-1 shred, and scanning ciphertext for plaintext needles is meaningless by construction. The exclusion rule itself is tested.
The blocklist is irrevocable in v1
Every blocklist row is written with release_supported: false, and entries are append-only: there is no release action and no operator delete. A future counsel-mandated release would be a new controlled ledger event (gdpr_blocklist_release), not built in v1. Re-ingesting pre-redaction bytes is refused at the request path, the child-enqueue path, and the incremental-skip path with INGEST_ERASED_CONTENT.
Revoking an API key does not evict it from a running server
RevokeApiKey updates api_keys.json on disk, but a server process already holding the key table in memory keeps honoring the old key until it restarts or reloads. The completion report carries this as an explicit warning rather than a silent gap.
Shared blobs (the common case for a mailbox attachment referenced by many messages) are masked once, for every referrer, at the same address: redacting a shared blob is a deliberate consequence of content addressing, not an accident, because forking the blob per-referrer would preserve the plaintext under the fork and defeat erasure entirely. All referrer consequences stay tenant-bounded, since content addressing itself is tenant-scoped.
Configuration¶
ErasureConfig (nested under the engine config as erasure):
| Key | Default | Meaning |
|---|---|---|
backup_horizon_days | 35 | Backups older than this age out naturally; restores inside the horizon must replay pending erasures from the ledger before serving |
log_retention_days_max | 30 | Operational log retention bound attested in the completion report; the GA gate is <= 30 days |
log_targets | app_logs, worker_logs, reverse_proxy_logs, job_runner_logs, cloud_provider_logs, error_tracking, support_exports | Log targets the operator attests are covered by the bound |
per_subject_log_scrub_supported | false | Whether this deployment supports per-subject log scrubbing (v1 core does not) |
expect_pg_stage | false | Whether an app-layer PostgreSQL stage must report before verification; deployments without it record not_applicable with a loud warning |
Deliberately not configurable
The spec is explicit that some things are doctrine, not knobs: the masking semantics (same-length, codepoint-widened ranges), the blocklist check on every dedup path, verification-scan-before-close, ledger and program emission, dual approval, the replay-before-serve rule on restore, and the archive supersession rule. None of these has a config flag to turn it off.
A deployment whose configured log_retention_days_max exceeds 30 sets manual_log_erasure_required: true on every completion report, and the runbook is expected to back that with a manual per-subject log-erasure procedure.
As-built notes¶
Per the spec's §14 changelog, the current build (v0.6, PR-081 CORE leg) landed:
- Subject descriptor, request/approval records, the full
/admin/erasure/*family (behind the admin token audience), and thetelha eraseCLI family. - Canonical-blob same-length
0x2Amasking (span-derived ranges unioned with Aho-Corasick needle matches, NFC-normalized with case variants, codepoint-widened) with blob redaction records at the reserved chunk indexu64::MAX. - The keyed-digest blocklist (
hmac_blake3_v1), wired into all three dedup paths (request-level, submit-time canonical, child-enqueue), returningINGEST_ERASED_CONTENT. - Content-free erasure programs plus idempotent restore replay: backup restore now lifts the earlier PR-083 rule that refused any restore whenever a ledger was present at all.
- Archive rewrite-verify-destroy supersession, with side-load refusal for any manifest recorded as superseded.
- Both PR-064 pinned gaps closed: clarification prior-version purge, and job-payload
ranking/content_b64scrubbing. - Trace masking (
draftand claim text) with a new, distinctevidence_redactedclaim-verification verdict. api_keys.jsonperson-binding revocation.- The two-pass verification scan with the residual-review closure model (strength classes, absolute zero for strong identifiers).
- The completion report, including the OQ-6 log-retention attestation block.
Deferred to the app leg (not yet built, but its core-facing contract is defined in this spec): the BullMQ PostgreSQL erasure worker, subject- scoped DEK shredding, and the subject index. Their surfaces, the stage/pg report-back endpoint, the Tier-1 ordering requirement, and the per-table register, are already normative here so the app leg has a fixed contract to build against.
Several open questions carry pinned engineering positions with counsel confirmation still pending, and the build proceeds on the conservative default in each case: no residual-review waivers are exercised by default (OQ-4), the blocklist has no release action in v1 (OQ-5), the 30-day log bound is documented as the GA gate rather than enforced retroactively (OQ-6), and PostgreSQL rows redact-by-default rather than delete (OQ-7). Legal sign-off remains the outstanding gate for GA; it does not block the build itself.
Related¶
- Field encryption - the envelope format and the subject-scoped DEK derivation that makes Tier-1 crypto-shredding possible.
- Tombstone cascade - ordinary, non-erasure deletion semantics that the append-only model already supports.
- Snapshot & compare - how temporal queries treat tombstones versus a physically erased entity.
- Concepts › The tritemporal model - why history is append-only everywhere except here.
- Concepts › Tenancy & security - the tenant-prefixed key layout that keeps every erasure program bounded to one tenant.
- Operators › Retention - the shared executor and ledger this spec consumes, and the policy/hold precedence erasure sits above.