Erasures & GDPR
Streams are immutable except for erasures: delete every entry matching a CEL predicate, in the stream and in every materialization it feeds, with a status you can prove.
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 whichfilteristrue. Later inserts are never affected. - A predicate that errors on an entry (for example a missing field) does not select it.
filterseesentry,id,cursor,batchIdandcommittedAt, 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
/changesfrom 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 reportsdeletionCurrent: falseuntil it has applied every erasure, and the change feed's barrier prevents a consumer from skipping one.
A GDPR deletion request, end to end
# 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 … SELECTor materialized views) is outside these guarantees. See materializations. - Session replay records only what the privacy rules allow, so most personal data is never captured in the first place.