Erasure and Legal Holds
Erase a person, an outside party or a whole tenant with one job and one receipt, keep data under a legal hold, and forget by the keys a client already holds.
0.14.0 adds three erasures that span a tenant (a person, an outside party, a whole tenant),
legal holds that keep what they name from every forget, erasure and retention sweep, and a
forget by key that deletes what a client wrote without reading anything first. Each erasure is one
job with one receipt of ids, counts and hashes, which GET /v1/erasures/{id}/verify checks against
every store. The single-scope erasure (POST /v1/erasures) works as before, with the stricter selectors
below; see Erasures.
Erase a person
POST /v1/erasures/person (operator key) erases what one person holds in a tenant, in order: their own
region and everything under it, their homes in every application, their own messages in the tenant's
groups, and last their directory entry.
curl -sS -X POST "$CORTEX_URL/v1/erasures/person" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"scope": "org:acme", "person": "user:bob", "confirm": true, "reason": "DSR-1234"}'{"erasure_id": "erasure_01a186b653c371038d48c0c3610f13a8", "status": "completed",
"manifest_url": "/v1/erasures/erasure_01a186b653c371038d48c0c3610f13a8",
"receipt_url": "/v1/erasures/erasure_01a186b653c371038d48c0c3610f13a8/receipt",
"verify_url": "/v1/erasures/erasure_01a186b653c371038d48c0c3610f13a8/verify",
"lifecycle_stream": "/v1/lifecycle/stream?erasure_id=erasure_01a186b653c371038d48c0c3610f13a8",
"audit_id": "audit_01a186b653ca79628fbf69a2ea382270"}The receipt (kind: "person") counts what went per scope, and lists under review the scopes outside
those parts that still hold events about the person, for you to decide. reason (at most 256 bytes, no
personal data) is checked and written nowhere. While one person's erasure runs, a second for the same
person answers 409 DESTRUCTIVE_OPERATION_IN_PROGRESS, naming the running job.
Erase an outside party
A customer or contact who is not a principal of the tenant is erased by email address, through the
party:<address> labels your writers put on events that concern them:
curl -sS -X POST "$CORTEX_URL/v1/erasures" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"scope": "org:acme", "subject": {"email": "jane@example.com"}, "confirm": true}'Every labelled event in every scope of the tenant goes, archived and expired ones included. Only
labelled events are found, never one that names the address in its text alone, so label at capture
("context": {"labels": ["party:jane@example.com"]}). The receipt (kind: "party") names the address only
by a hash, counts the events per scope and says coverage: "labelled_only".
Delete a tenant
POST /v1/erasures/tenant (operator key) deletes a tenant: its memory, its directory and its registry.
It is a dry run unless you send "dry_run": false with "confirm": true:
curl -sS -X POST "$CORTEX_URL/v1/erasures/tenant" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"scope": "org:acme"}'
# {"scope":"org:acme","dry_run":true,"counts":{"records":7,"events":3,"directory_entries":1,…},"holds":[]}From the deletion's first step every credential of the tenant answers 410 TENANT_DELETED, and its
/verify proves it as a whole-scope erasure. While any legal hold of the tenant is in force the route
answers 409 HOLD_IN_FORCE, dry run or not: release the holds first.
Legal holds
A hold record (<tenant>/hold:<id>, an opaque id that never names the matter) keeps the events of
the scopes it names and, for each person it names, the events that person wrote, observed or is the
subject of, within an optional window (from inclusive, until exclusive). What was derived from a held
event (facts, beliefs, episodes, rollups) stays with it. A hold is written with a change set:
curl -sS -X POST "$CORTEX_URL/v1/access/changes" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{
"scope": "org:acme", "id": "hold-m-2026-114", "reason": "Legal hold",
"parts": [{"part": 0, "records": [
{"path": "org:acme/hold:m-2026-114", "op": "put", "record": {
"members": [{"actor": "user:admin", "role": "owner"}],
"policies": {}, "auto_provisioned": false, "created_at": "2026-10-29T00:00:00Z",
"tree": {"kind": "hold", "parent": "org:acme"},
"hold": {"matter": "Matter 2026-114", "people": [{"actor": "user:bob", "from": "2026-01-01T00:00:00Z"}]}
}}
]}]
}'
# then preview and activate it (Change Sets)The matter text is stored apart from the record and can be erased on its own
(POST /v1/access/texts/erase); the record keeps only a reference. Add "scopes": ["org:acme/team:sales"]
to hold whole scopes.
While the hold is in force:
- an erasure or forget keeps every held event and lists it in its receipt (
held_event_ids), never among what was erased; a person's erasure also keeps the held person's directory entry; - the previews count what a hold keeps (
events_under_legal_hold,derived_under_legal_hold); - retention sweeps keep held events;
- a tenant's deletion is refused (
409 HOLD_IN_FORCE).
A hold is released only by a change to its record (a change set that deletes or narrows it), never by a date passing. Once released, the server runs the erasures it kept pending, as their jobs asked.
The receipt of Bob's erasure while a hold on Bob was in force (trimmed): the hold kept his two events and his directory entry, pending until its release.
{"kind": "person",
"person_erasure": {"parts": {}, "review": {"org:acme/team:eng": 1},
"held": ["org:acme/user:bob", "person"], "directory_erased": false},
"held_event_ids": ["evt_01a186b653477111a1c70eee5fa6a761", "evt_01a186b6535b7ab1a2ebfc1496cda862"]}Forget by key
A client that wrote with idempotency keys can forget those writes without reading anything first:
selector.idempotency_keys (up to 1,024 {key, caller?}; a key's claim lasts 24 hours) and
selector.capture_keys (up to 64 capture labels such as cap:<capture>). The capability
forget.by_key admits such a forget alone, so a clean-up service's token needs no read capability (like
every forget by a token, it needs the Owner role where it forgets). It reads nothing, so under the deny
operator mode the operator key needs no break-glass header for it.
curl -sS -X POST "$CORTEX_URL/v1/forget" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"scope": "org:acme/team:eng",
"selector": {"idempotency_keys": [{"key": "eng-0001", "caller": "user:bob"}]},
"audit_note": "withdrawn"}'
# {"deleted":{"events":1,"episodes":0,"facts":0,"beliefs":0,"understanding":0},"requested":1,"matched":1,…}caller is whose write the key was (the request's own principal when left out). /v1/forget/preview
takes the same selector. The erasure routes take neither key selector (422, key_selector_not_supported).
Strict selectors
Since 0.14.0 a forget or erasure refuses a selector field it does not apply (422 INVALID_BODY,
unknown_selector_field), where it used to ignore it: a misspelt field can no longer widen what is
deleted. A forget's layers outside events, episodes, facts, beliefs, understanding and
code is unknown_layer.
See also
Applications and Sources
Register applications with a home per person and mounted sources, sync sources into containers that follow each source's own sharing, and see how agent writes carry what they read.
Code Intelligence Plane
Index a repository into CortexDB and retrieve task-scoped, cited code context.