# Erasures & GDPR

Streams are immutable except for **erasures**: delete every entry matching a [CEL](https://capless.sh/docs/cel.md) predicate, in the stream and in every [materialization](https://capless.sh/docs/materializations.md) it feeds, with a status you can prove.

```sh
H="authorization: Bearer $CAPLESS_API_KEY"

curl -s https://api.capless.sh/v1/streams/web.events/erasures -H "$H" \
  -d '{"idempotencyKey":"gdpr-request-123","filter":"entry.userId == \"user_42\""}'
# 202 → {"id":"era_…","status":"pending","upToCursor":"cur_…", …}

curl -s https://api.capless.sh/v1/streams/web.events/erasures/era_… -H "$H"          # status, progress, downstream
curl -s https://api.capless.sh/v1/streams/web.events/erasures/era_…/entries -H "$H"  # exact affected IDs
curl -s https://api.capless.sh/v1/streams/web.events/erasures -H "$H"                # all erasures, stream order
```

For web analytics, erase both streams: `web.events` and `web.replay`.

## Scope

* An erasure occupies a position in the stream's history, ordered with batches.
* It affects entries strictly before its `upToCursor` (default: the head when it is accepted) for which `filter` is `true`. **Later inserts are never affected.**
* A predicate that errors on an entry (for example a missing field) does not select it.
* `filter` sees `entry`, `id`, `cursor`, `batchId` and `committedAt`, so you can erase by time too: `committedAt < timestamp("2026-01-01T00:00:00Z")`.

## Status

| Status | Meaning |
|---|---|
| `pending`, `resolving` | Accepted. Every reader (`/read`, `/changes`, materializers) **already hides** matching entries by evaluating the predicate on the fly. |
| `logically_applied` | The exact affected set is recorded; `GET …/erasures/{id}/entries` lists it (paginated, cursor order). Earlier: `409 erasure_not_resolved`. |
| `physically_purged` | Every storage segment that held an affected record was rewritten without it, and the superseded objects were deleted from canonical storage (after a short grace period for in-flight readers, 10 minutes by default). |

`progress` reports `scannedEntries`, `affectedCount`, `affectedBatches` and `purgedBatches`; `downstream` lists every materialization source and whether it has `applied` the erasure.

## Guarantees

* **Immediate**: matching entries are hidden from every reader from the moment the erasure is accepted.
* **Never resurrected**: replaying `/changes` from any cursor omits inserts covered by any accepted erasure.
* **Physical**: affected records are removed from storage, not just flagged. Offsets are preserved (a purged record becomes an empty line), so cursors stay valid.
* **Downstream, exactly**: materializations delete the affected rows by their lineage column (`_capless_id`). A source reports `deletionCurrent: false` until it has applied every erasure, and the change feed's barrier prevents a consumer from skipping one.

## A GDPR deletion request, end to end

```sh
# 1. Erase the user everywhere their data lives
for S in web.events web.replay orders; do
  curl -s https://api.capless.sh/v1/streams/$S/erasures -H "$H" \
    -d "{\"idempotencyKey\":\"dsr-881-$S\",\"filter\":\"has(entry.userId) && entry.userId == 'user_42'\"}"
done

# 2. Poll until physically_purged and every downstream source has applied it
curl -s https://api.capless.sh/v1/streams/orders/erasures/era_… -H "$H"

# 3. Keep the affected IDs as your record of what was deleted
curl -s "https://api.capless.sh/v1/streams/orders/erasures/era_…/entries?limit=10000" -H "$H"
```

## Caveats

* Data you copied out of Capless (exports, derived ClickHouse tables built with `INSERT … SELECT` or materialized views) is outside these guarantees. See [materializations](https://capless.sh/docs/materializations.md#supported-and-unsupported-patterns).
* Session replay records only what the [privacy rules](https://capless.sh/docs/web-sdk.md#session-replay-privacy) allow, so most personal data is never captured in the first place.
