CortexDB Docs
Docs for CortexDB 0.14.0, the version running on CortexDB Cloud (api.cortexdb.ai).
Shared Memory

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.

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

On this page