Capless
Docs Erasures & GDPR

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 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

StatusMeaning
pending, resolvingAccepted. Every reader (/read, /changes, materializers) already hides matching entries by evaluating the predicate on the fly.
logically_appliedThe exact affected set is recorded; GET …/erasures/{id}/entries lists it (paginated, cursor order). Earlier: 409 erasure_not_resolved.
physically_purgedEvery 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

# 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.
  • Session replay records only what the privacy rules allow, so most personal data is never captured in the first place.