Guides
Deletion and erasure
If you store anything you read from Tahoe, this page describes obligations rather than features. Three signals, three different responses.
Three signals
| Signal | Means | You must |
|---|---|---|
*.deleted events | A record was removed from Tahoe. | Delete your copy of that record. |
applicant.unsubscribed | They asked not to be contacted. | Stop contacting them. Keep the record; stop the outreach. |
data_subject.suppression_applied | They exercised a right, or a takedown was required. | Delete your copy within 7 days and stop processing them. |
Deletion events
job.deleted, application.deleted, applicant.deleted, sourced_profile.deleted, list.deleted and list_membership.removed.
Their payload carries identifiers and a reason, never the data that was deleted. The reason is one of user_deleted, workspace_deleted, data_subject_erasure, retention_expiry, legal_hold_release, provider_takedown or suppression.
Your action does not vary by reason — delete the record. Log the reason so you can answer why later, and so a provider takedown is distinguishable from a person’s own request.
DELETIONS = {
"job.deleted": "job",
"application.deleted": "application",
"applicant.deleted": "applicant",
"sourced_profile.deleted": "sourced_profile",
"list.deleted": "list",
"list_membership.removed": "list_membership",
}
def handle_deletion(store, event):
object_type = DELETIONS[event["type"]]
handle = event["data"]["object"]["id"]
reason = (event["data"]["object"].get("reason")
or event["data"].get("reason"))
# Delete, do not soft-delete into a table that still answers queries. A
# tombstone that keeps the personal data is not a deletion.
store.purge(object_type, handle)
store.audit("deleted", object_type, handle, reason=reason)Unsubscribes are narrower, and absolute
applicant.unsubscribed means stop contacting, not stop holding. Keep the application record — your customer still needs to run their pipeline — but that person must not enter an outreach sequence again.
Suppression: the seven-day clock
This is the strongest signal in the API. Read it from the erasure feed or as the data_subject.suppression_applied event.
{
"object": "erasure",
"id": "ers_5Nx3jLm7Qd2s",
"action": "suppression_applied",
"reason": "data_subject_erasure",
"action_required": "cease_processing_and_delete_your_copy",
"workspace_id": null,
"at": "2026-09-07T14:02:11.408Z",
"deadline_at": "2026-09-14T14:02:11.408Z",
"destruction_pending": true,
"destruction_reason": "retention_floor",
"resources": [
{ "object": "sourced_profile", "id": "cnd_8Fj3kLm2Qd7s", "workspace_id": "wsp_4Kd8sPm2Qx7L" }
]
}Three things about that payload:
deadline_atis seven days out. That is your window.destruction_pending: truemeans Tahoe itself is still holding the record under a retention floor it cannot lawfully ignore — employment records have statutory retention in several jurisdictions, up to four years. This has no bearing on your deadline.workspace_id: nullmeans the notice belongs to no single workspace — a pool suppression obliges every holder of that person’s data, so it is delivered to every credential.
def honour_suppression(store, notice):
"""Delete our copy and record that we did, within the 7-day window."""
for resource in notice["resources"]:
store.purge(resource["object"], resource["id"])
# A tombstone, not a soft delete: this must keep the person OUT, so a later
# sync cannot re-harvest them the next time they appear in a list. Store the
# handle and nothing else about them.
for resource in notice["resources"]:
store.suppress_forever(resource["object"], resource["id"])
store.audit(
"suppressed",
notice_id=notice["id"],
reason=notice["reason"],
deadline_at=notice["deadline_at"],
)Write a tombstone, and never expire it
Reconcile, do not assume
If your consumer has been down, catch up on the erasure feed first. It is small, cursored independently of your event watermark, and it is the feed where being behind actually matters.
def catch_up(session, store):
# Erasures first. If we are going to re-read lists and re-create rows, the
# suppression list must be current BEFORE we write anything, or we will
# re-harvest someone who asked to be removed.
consume_erasures(session, store)
drain(session, store)Checklist
- Consume the change feed or webhooks. Deletions arrive no other way.
- Handle all six
*.deleted/*.removedevents. - Check
unsubscribedbefore every send. - Consume the erasure feed at least daily.
- Act on
suppression_applied, never onerasure_completed. - Do not filter the erasure feed by workspace.
- Keep a permanent, never-expiring suppression list.
- Log what you deleted, when, and under which notice.
