EuroStream
GDPR-native streaming and medallion lakehouse for European commerce: real-time fraud scoring, DuckDB and Turso serving, and a six-layer Article 17 erasure cascade with a verifiable audit trail.
- erasure cascade
- 6 layers
- mean erasure
- 66.95 ms
- p95 erasure
- 109.2 ms
- statutory SLA
- 60 s
- fraud rules
- 3
- test suite
- 59 tests
- type check
- mypy strict
The whole system on one page: ingest, score, transform, serve and erase. Open the image for the full-size animated version.
EuroStream started from a conflict we kept running into in European data work. Message logs append forever and lakehouse files are immutable by design, while GDPR Article 17 says a person can ask for their data to be erased. Bolting a delete endpoint onto that stack leaves copies behind in partitions, aggregates and in-memory state.
So we built the pipeline around erasure instead. A deletion request runs as an atomic six-layer transaction, and a verification endpoint proves what remains: zero rows in Silver and Gold, zero clear-text PII in Bronze, and one audit entry carrying a truncated hash.
The problem in one paragraph
Kafka partitions, Parquet files and materialized aggregates are append-only by nature. Erasing one person means rewriting history across every layer, and the usual fix is a manual script that misses a copy somewhere. A missed copy can mean a fine under Article 83(5) and a public incident, so deletion here is a pipeline operation with tests, not a support ticket.
How it works

- A producer writes synthetic EU orders, clicks and payments onto the bus. Locally that is SQLite in WAL mode; in production it is Aiven Kafka over SASL_SSL with SCRAM-SHA-256.
- The streaming engine consumes payments and scores them against three rules: a velocity spike (more than 5 payments in 300 seconds), an amount z-score above 3.0 computed with sample variance, and a billing-to-merchant country mismatch. Suppressed customers are dropped before any rule runs.
- Every four hours the batch DAG runs watermarked incremental merges. Bronze keeps raw events, Silver deduplicates on natural keys and replaces PII with salted SHA-256 pseudonyms, and Gold builds the consent-gated marts.
- Six quality assertions run after the merge, including uniqueness on both marts, a check that no clear-text email or IBAN pattern survives in Silver, and consent gating against the upstream flag.
- Changed Silver and Gold tables export to de-identified Parquet and sync to the Hugging Face dataset. Only files whose content hash changed are pushed.
- The FastAPI app reads the local DuckDB file during development or the Turso cloud replica in production, and serves the dashboard, the REST API and the Prometheus exposition.
The six-layer erasure cascade
Every layer that could hold a copy of the customer gets touched in one ordered transaction:
- Suppression registry. The customer id enters an in-memory set and the
governance.suppression_registrytable, so replayed or delayed events are dropped before they reach a consumer. - Bronze anonymization. Raw rows are masked in place (
email,iban,ipbecome<anonymized>) so ledger ordering and row counts survive. - Silver hard delete. Pseudonymized customer, order and payment rows are removed.
- Gold hard delete. The Customer 360 aggregate, order facts and fraud summary rows are removed, so no ghost record keeps lifetime spend.
- Streaming memory purge. The fraud scorer evicts the customer's window state and alert history, then their Bronze alerts are deleted.
- Lake re-snapshot. Silver and Gold Parquet partitions are rewritten and synced, and the audit log stores
sha256(request_id : customer_id)[0:16].
The verification endpoint returns the counts a regulator would ask for:
{
"customer_id": "cust_424242",
"verified": true,
"is_suppressed": true,
"gold_rows_remaining": 0,
"silver_rows_remaining": 0,
"bronze_clear_text_rows": 0,
"bronze_anonymized_rows": 60,
"audit_log_entries": 1
}
The benchmark suite runs 50 erasures end to end: 66.95 ms mean, 61.84 ms median, 109.20 ms p95 and 110.07 ms worst case. The statutory window is 60 seconds, so the slowest run finished with plenty of room, and erasure_sla_breaches_total stays at 0.
Three fraud rules and one gate
- Velocity. A customer with more than 5 payments inside a 300 second window gets one alert per tumbling window, which keeps a burst from becoming an alert flood.
- Amount z-score. Each payment is compared against the customer's own history with sample standard deviation, and the score has to pass 3.0. The history is a bounded deque of 200 amounts with a sweep every 50 events, so memory cannot grow without limit.
- Geo mismatch. The issuing bank country and the merchant country are compared directly, which catches card testing that stays under the amount threshold.
- Suppression gate. Every rule checks the suppression registry first. An erased customer cannot produce an alert, and their state is gone from memory as well as from storage.
Governance that runs in CI
The PII classifier does not trust patterns alone. European IBANs are validated with the ISO 13616 / ISO 7064 mod-97 checksum, which stops the false positives that regex matching produces on UUIDs and long order ids. An early version of this check flagged valid UUIDs, and the repository keeps the postmortem for it.
Schema drift is handled the same way. eurostream contracts --baseline governance/contracts.json compares the live models against a committed baseline, and CI blocks any pull request that adds an unclassified column or breaks an existing contract. The rest of the gate is ruff, mypy in strict mode across 23 source files, and the pytest suite.
Local now, cloud later
Every component sits behind one interface, so the same code runs on a laptop and in production:
- Event bus.
SqliteBuslocally,KafkaBuson Aiven whenEUROSTREAM_EVENT_BUS_BACKEND=kafka. - Warehouse. Embedded DuckDB for analytics, with every write mirrored to a Turso libSQL replica over HTTP.
- Lake. Parquet under
data/lake/, synced to theswadhinbiswas/eustreamdataset on Hugging Face. - API. Uvicorn on port 7860 locally, a Docker container on Render in production.
- Orchestration. The CLI or cron locally, a scheduled GitHub Actions DAG every four hours in production.
The dual-engine setup is what keeps governance state alive. Containers restart, but suppression sets and watermarks live in DuckDB and Turso, so a fresh process reconstitutes them on boot instead of losing them with the container.
Observability
The app exposes Prometheus counters and a latency summary at /metrics/prometheus, which is enough for Grafana to scrape erasure throughput, SLA breaches and fraud alerts split by rule. The dashboard also has five live views: overview, fraud intelligence, Medallion and Customer 360, the Article 17 console, and a Prometheus explorer.
Stack
| Part | Technology |
|---|---|
| Language | Python 3.11+, mypy strict |
| Event bus | SQLite WAL, Aiven Kafka |
| Warehouse | DuckDB, Turso libSQL |
| Lake | Parquet on Hugging Face |
| API | FastAPI, Uvicorn |
| Orchestration | GitHub Actions, 4-hour cron |
| Deploy | Docker, Render |
| Quality | pytest, ruff, contract gate |
Links
- Live demo: eurostream-api.onrender.com (free instance, the first request wakes it in about 20 seconds)
- Dataset lake: swadhinbiswas/eustream
- Source: github.com/swadhinbiswas/eurostream
- Docs: architecture RFC, ADRs and the erasure flow
- Paper: JOSS manuscript draft
The hard part
What made it hard
Erasure and immutability pull in opposite directions
Kafka wants append-only partitions, Parquet wants immutable files, and Article 17 wants a person gone. Rewriting topic partitions is not an option, so the cascade works with the grain of each layer instead: Bronze rows are masked in place to keep ledger ordering intact, Silver and Gold rows are hard deleted, the fraud scorer's in-memory state is evicted, and the public lake partitions are rewritten from the corrected tables. A suppression registry catches replayed and delayed events before they can resurrect the customer.
Ghost records hide in the aggregates
The first working version deleted from Silver and stopped there. gold.customer_360 still carried lifetime spend, order counts and marketing flags, which is exactly the kind of copy an audit finds. The verification endpoint now counts rows across every tier, so a missed layer fails a test instead of surfacing in a data subject response.
The first IBAN check flagged UUIDs
Pattern-only PII detection marked long hex strings as bank accounts and blocked valid events. The classifier now runs the ISO 13616 / ISO 7064 mod-97 checksum on candidates, which keeps the catch rate on real IBANs and removes the false positives. The repository keeps the postmortem for the incident, including the test that reproduces it.
In-memory state does not survive a container restart
Suppression sets started as a process-local set. On Render, a restart quietly cleared them and replayed events could write data for an erased customer. Suppression now lives in the DuckDB warehouse and the Turso replica, and the in-memory set is a cache that rebuilds from durable state on boot.
A drifting column should fail the build
Compliance work breaks when someone adds delivery_notes to an event without classifying it. The contract baseline gate compares every model against a committed schema file in CI, and mypy strict runs over the pipeline so a wrong type in the erasure path fails before a deploy. Both gates exist because the first version of the cascade had a silent type mismatch between the Bronze mask and its verification query.
Outcome
What exists today
- One command runs the whole story end to end: produce events, score fraud, transform the medallion layers, erase a customer and verify the result
- The six-layer cascade averages 66.95 ms per erasure over 50 benchmark iterations, with a worst case of 110.07 ms against a 60 second statutory window
- 59 tests cover the IBAN checksum, z-score bounds, suppression gating, cascade integrity, watermark advances and poison-pill handling
- mypy runs strict across 23 source files, ruff is clean, and the contract baseline gate blocks unclassified schema changes
- A public, de-identified Parquet lake on Hugging Face with the Silver and Gold tables
- A FastAPI app with five live views, including the Article 17 console that visualizes the cascade
- An architecture RFC, three ADRs, a documented erasure flow and an incident postmortem in the repository
- A JOSS manuscript draft describing the design and the benchmark methodology
What I'd do differently
If we built it again
- Treat erasure as a transaction from the first commit. The suppression registry and the verification endpoint arrived after the ghost-record bug, when they should have been part of the schema.
- Write the verification query before the deletion code. Deleting rows is easy to write and hard to prove, and the counts across six layers are what actually make the cascade trustworthy.
- Keep governance state out of the process from day one. Moving suppression sets into DuckDB and Turso fixed a real failure mode rather than an imagined one.
- Put the contract baseline gate next to the models it checks. It started as a separate script that drifted from the schema, which defeats the point of a gate.
- Run the cloud engines from the beginning of development. Dual-writing to Turso every cycle found synchronization bugs that a local-only workflow would have hidden until deployment.