Roadmap To Be A Data Engineer / Lesson 35
Who Is Allowed To Ask?
Every query was authorised, and none recorded why. Purpose checked at query time.
1The scene
A letter arrives from the Hamburg data protection authority. A woman who bought a coat in March, returned it, and unsubscribed from marketing in April kept getting emails about reduced prices on items like the one she sent back. She asks one question: on what legal basis was her data used for that, and can the company show it?
The access review comes back in twenty minutes and it is excellent. Nobody had access they should not have had. All 134,683 queries in the quarter were authorised and the audit log says so for every one. Lesson 23 ended here, with every door locked and the house wide open; this time the doors really are the right doors.
The review answers who was allowed to read this table. The authority asked why was this person’s data used for this. The warehouse holds the answer to one of those.
2The field that does not exist
Art. 30(1)(b) requires a record of processing activities whose second item is
“the purposes of the processing“. The company has one: a spreadsheet with six
rows. INFORMATION_SCHEMA.JOBS has a record too — who, when, which tables and
columns, how many bytes, success or failure. It has no field for why, so the
honest count is not low, it is 0 of 134,683.
Every instrument in the platform records what was computed. Lawfulness is a property of why. An access log is a record of production, not of purpose, which is why it can be perfect and answer nothing.
3Two clocks, and everybody watched the fast one
Consent lives in the consent platform, which records a change the instant it
happens. mart_customer.marketing_opt_in is rebuilt nightly at 03:00; every
campaign extract runs after 05:00. The warehouse copy is never more than
4 hours old. That number is on a dashboard and it is green.
What goes out in an email is not that column. It is a list built from it earlier and then used.
| Warehouse copy | S1 Newsletter | S2 Price-drop | S3 Win-back | |
|---|---|---|---|---|
| List rebuilt | every night | weekly | monthly | quarterly |
| Oldest list mailed | — | 2 d | 30 d | 90 d |
| Mean consent age at send | 4 h | 2.29 d | 15.06 d | 48.02 d |
Across all 6,705,694 marketing emails the mean is 14.57 days — 87× the staleness of the column everyone watches. Art. 7(3) gives the right to withdraw consent “at any time”; it has been implemented against a list refreshed four times a year.
4What that costs, counted
6,850 people withdrew consent this year. Joining every one of 6,705,694 sends against each recipient’s consent at the instant of the send:
- 19,611 emails went to someone who had withdrawn (0.2925% of all marketing mail).
- They reached 3,937 people — 57.5% of everyone who withdrew.
- One person received 18 of them.
And the harm is not where the volume is. The newsletter is 68.9% of the email and 10.3% of the harm; the win-back campaign is 25.2% of the email and 83.5% of the harm. Rate per send: 0.044%, 0.304%, 0.970%.
The baseline inverts the story. Before automation the list was a quarterly CSV pasted into the campaign tool. Measured on the same consent history that process was worse per email — 0.522% against 0.292% — and sent 7,851 emails a year instead of 6,705,694. The automation improved the rate 1.79× and multiplied the harm 478×. Lesson 13’s shape again: the fix did not introduce the error, it removed the ceiling on it.
5The record, and what the warehouse did instead
Score the quarter’s jobs against the matrix the Art. 30 record implies (six purposes × nine categories of personal data):
- 10,171 jobs (7.55%) read a category the record does not list for the purpose they served. Nameable, not malicious: 2,656 analytics jobs pulled the email address as a join key, 1,739 order-fulfilment jobs read fraud signals, 1,157 marketing jobs read the delivery address.
- Of 31,138 marketing jobs, 18,961 read a contactable or behavioural column and never read the consent flag — 14.08% of every job in the quarter. 5,943 of those produced output that left the platform.
6The alarm that is already in the log
No new instrumentation needed: did a job whose output left the platform read the email address and never read the consent column?
| Rule | Fires / quarter | Per day | Precision | Recall |
|---|---|---|---|---|
| Reads email, no consent column | 45,560 | 495 | 37.0% | — |
| …and the output left the platform | 8,834 | 96 | 60.1% | 89.3% |
| …and the job declares its purpose | 5,943 | 65 | 100% | 100% |
The second rule is free and catches 89.3% of the unlawful exports.
Its ceiling is the missing field: 3,528 of its 8,834 fires are
transactional mail, which legitimately reads an email address and needs no
consent — and no SQL can tell them apart, because the difference is the purpose.
One job label (--label=purpose:marketing) makes it exact. That 100% is true by
construction and should be read as a measure of what the field is worth, not a
claim that labels cannot lie: a declared purpose is evidence, not a control
(Lesson 32’s line about the knowledge axis).
7The thing everybody reaches for
Differential privacy, read from GoogleSQL’s own reference rather than the pitch.
- The ε you write is not the ε you get. “Each aggregate individually gets
epsilon/(n+1)… If used withmax_groups_contributed, the effective epsilon per function per groups is further split bymax_groups_contributed.” Here that is ε/9. - The budget is a publication schedule. A tile refreshing hourly asks the same question 2,208 times a quarter; a budget of ε = 10 at 0.5 a query affords 20 of them — 0.91% — and the view stops answering at 20:00 on day one. Spread evenly, ε = 0.00453 and the median group count is 429% wrong; once a week, ε = 0.7692 and 2.53%.
- The only affordable architecture is one DP answer, computed rarely and served from a copy — exactly the detached copy Lesson 24 argued against. Under a budget, the copy is correct.
- The documented recovery voids the guarantee: “when a privacy budget is exhausted… the view can no longer be queried and must be updated or re-created”, and “when you update a differential privacy analysis rule, the privacy budgets are reset.”
- Let it win where it should. On the same report, Lesson 23’s k = 50 suppression keeps 56 of 1,344 cells (4.17%); DP keeps all of them at 2.53% median error weekly. For Lesson 23’s question it is clearly the better instrument.
- And it is orthogonal to this one. Nobody singled the complainant out of a report. She was emailed.
- One more default: “If
max_groups_contributedis unspecified, then there is no limit…” and “the language can’t guarantee that the results will be differentially private.” The parameter that makes the guarantee real is the optional one. (Noise “can be eliminated by settingepsilonto1e20.”)
8Where to attach the policy
Row access policies are real enforcement — “with subquery support, row access
policies can reference other tables and use them as lookup tables”, and
SESSION_USER() works. But: “row-level access policy filters don’t participate
in query pruning“, and Lesson 10 established that pruning is the whole cost
mechanism.
| Policy on | Scanned per tile run | On-demand equivalent / yr |
|---|---|---|
fct_order_item (2.41 TiB, 730 partitions) |
3.38 GiB → 2,468 GiB | €1,705 → €1,244,925 |
dim_customer + the consent lookup |
55 MiB | €27.09 |
On the fact table it costs 28.4× the entire warehouse bill.
On the smallest table that names the person, €27.09. Where you put
the policy is worth a factor of 45,947. And the Lesson 23 trap repeats:
“when a table is replaced using the CREATE OR REPLACE TABLE DDL statement, all
existing row access policies on the original table are dropped” — which is what
every full-refresh model does nightly.
9What each fix actually removes
| Fix | Emails after withdrawal removed | Unlawful reads removed | Cost |
|---|---|---|---|
Rebuild mart_customer hourly |
0 of 19,611 | 0 | €1,860 + €180/yr |
| Rebuild every segment at send time | 19,549 (99.68%) | 0 | €4,960 |
| Both | 19,572 (99.80%) | 0 | €6,820 + €180/yr |
| Ask the consent platform at send time | 19,611 (100%) | 0 | €8,680 + €1,240/yr |
Row access policy on dim_customer |
0 | 18,961 (100%) | €3,720 + €27.09/yr |
| Purpose label + weekly census | 0 | 0 | €6,820 |
| Consent as bitemporal data (L32) | 0 | 0 | €11,780 + €96/yr |
Three results worth saying out loud:
- A fresher warehouse copy removes exactly nothing — zero of 19,611, and structurally so: every extract already runs after the nightly build, so all of the staleness is in the frozen list. The ticket already in the backlog (“sync consent hourly”) would have changed no outcome at all.
- The row access policy removes no emails either. Every extract was correct when it ran. Query-time governance governs the moment of the query; the use happened up to ninety days later.
- The two evidence measures remove neither, and the regulator asked for evidence. Together they cost €18,600 and turn an unanswerable letter into a query.
10The detector belongs to the plaintiff
Of 19,611 unlawful emails to 3,937 people, 13 produced a support complaint and 1 reached the regulator: one per 19,611 emails, a detection rate of 0.0051%, latency six months. (Expected escalations on this population: 0.94 — one is the likeliest outcome, which is the point.)
The mirror: Art. 15 gives her the right to ask what was done with her data and
why. Today that is a person walking 14 systems by hand (Lesson 14’s copy
map) at 6.5 hours a request, 71 requests a year, €31,382.
The job log cannot help — it records tables and columns, not rows, so “which
queries read my row” is not a question INFORMATION_SCHEMA can answer at any
price. Instrumented, the same request is a query: 15 minutes,
€1,207 a year.
11What to ask at the standup
- Where is the purpose of a query recorded? If the answer is “in the analyst’s head”, we cannot answer an Art. 30 or Art. 15 question about any job we have ever run.
- How old is the consent decision at the moment we act on it — not how old the column is? Audience-weighted mean, by campaign.
- Which of our audiences are frozen, and for how long? A list rebuilt less often than people change their minds is a liability with a schedule.
- Which single table would a row access policy sit on, and what does it do to partition pruning?
- For any report we would protect with differential privacy: how often does it refresh, and what is the budget per refresh?
- If a regulator wrote about one named customer tomorrow, how many hours — and which are a query and which are a person opening tabs?
12Twenty minutes, hands on
pip install duckdb
python3 - <<'SQL'
import duckdb
con = duckdb.connect()
con.execute("""create table consent as select * from (values
(1, timestamp '2026-01-04 10:00', true), -- opted in at checkout
(1, timestamp '2026-04-11 21:14', false), -- unsubscribed, 21:14
(2, timestamp '2026-02-02 08:30', true)
) t(customer_id, changed_at, opted_in)""")
con.execute("""create table send as select * from (values
('win-back', 1, timestamp '2026-04-01 06:00', timestamp '2026-06-23 09:00'),
('newsletter', 1, timestamp '2026-06-17 07:00', timestamp '2026-06-19 10:00')
) t(stream, customer_id, audience_built_at, sent_at)""")
print(con.execute("""
select s.stream,
(select opted_in from consent c where c.customer_id = s.customer_id
and c.changed_at <= s.audience_built_at
order by c.changed_at desc limit 1) as consent_when_listed,
(select opted_in from consent c where c.customer_id = s.customer_id
and c.changed_at <= s.sent_at
order by c.changed_at desc limit 1) as consent_when_sent,
date_diff('day', s.audience_built_at, s.sent_at) as list_age_days
from send s""").fetchall())
SQL
Both rows come back True, False. The list was right when it was built and wrong
when it was used, and the only difference between the two sends is
list_age_days. Then, on your own platform: count the jobs in ninety days of
INFORMATION_SCHEMA.JOBS that referenced a contactable column and not your
consent column; ask your marketing tool for the build time of every audience it
holds and subtract it from the send time; add --label=purpose:<something> to one
scheduled query and confirm it comes back out of the job log.
13Takeaway
Governance in a warehouse is almost always built at storage time: who may read this table, which columns are masked, which rows a policy admits. All of it is necessary and none of it is about the question a regulator, a customer or a court actually asks, which is why a particular person’s data was used for a particular thing on a particular day. That question has three parts — who asked, what for, and what the person had agreed to at that moment — and a warehouse ordinarily stores one.
- An access log is a record of production, not of purpose. 134,683 jobs, 0 recording why. One job label takes the best available alarm from 60.1% precision to exact.
- Consent has two clocks and the industry instruments the fast one. 4 hours against a decision 14.57 days old on average. Making the column fresher removed 0 of 19,611 unlawful emails.
- Query-time control and use-time lawfulness are different problems. A row access policy removes 18,961 unlawful reads and 0 unlawful sends; a check at send time removes 19,611 unlawful sends and 0 unlawful reads. Buy both, knowing which is which.
The twenty-third failure shape — the unpurposed
Every row is accurate. Every access is authorised and the audit log says so 134,683 times out of 134,683. Every query returns exactly what it should, and the consent column it read was correct to within four hours. The unit of failure is a purpose, which is a property of the question and not of the data — so there is nothing in any table to assert about, nothing in any log to join to, and the thing that makes a processing lawful is the one thing the platform never recorded. It is distinct from Lesson 23’s permitted, where the control was correct and governed the wrong object; here the control is correct, governs the right object, and the defect lives in a dimension the control does not have. And it is the only shape in the catalogue whose sole working detector is the data subject: outside the company, uninstrumented, hit rate 0.0051%, latency months.
Vocabulary
| Term | Meaning |
|---|---|
| Purpose limitation | Art. 5(1)(b): data is collected for specified purposes and not further processed incompatibly with them. The only principle that is a property of the question rather than of the data. |
| Legal basis | One of the six grounds in Art. 6(1). A purpose whose basis is consent is the only one the data subject can switch off at any moment. |
| Record of processing (ROPA) | The Art. 30 register: purposes, categories of data, recipients, retention. Usually a spreadsheet; almost never reconciled against what the warehouse does. |
| Audience age | Send time minus the moment the list was built. The quantity that decides whether a marketing email was lawful, and the one nobody has on a dashboard. |
| Row access policy | A filter applied to every query on a table. Real enforcement, at query time only, whose filters do not participate in partition pruning — so attach it to the smallest table that names the person. |
| Privacy budget | A total ε across all queries on a protected view. Irreversible to spend; exhausting it stops the view answering, so in practice it sets how often a protected report may be recomputed. |
| Purpose label | A declared reason attached to a job. Evidence, not a control: it stops nothing, and without it the lawfulness of a query is not computable from anything the platform stores. |
14How these numbers were made
Three pieces, all run rather than described. The campaign arithmetic is a deterministic population of 300,000 customers (the people behind Lesson 19’s 356,360 rows) with a full year of consent history, three campaign streams on their real schedules, and 6,705,694 individual sends evaluated against each recipient’s consent at the instant of the send; no RNG — every quantity comes from a splitmix64 finaliser over integer keys. The job census is 134,683 synthetic jobs scored against a hand-written Article 30 matrix; the distribution of column reads is modelled, so the shares are this platform’s and the fact that each pair is a mismatch is the matrix’s. The differential privacy figures are closed form from GoogleSQL’s documented epsilon split (median |Laplace| = b·ln 2), an accounting of the documented parameters rather than a reimplementation of BigQuery’s noise.
Verification re-runs every published figure a second way — the campaign
arithmetic as SQL in DuckDB over the generator’s own CSVs, the privacy and cost
figures by closed form: 54 checks, 54 matched, 0 failed. Both source files
were extracted back out of the rendered artifact, run in a clean directory and
diffed: 505 scalar values, 0 differences, figures.json identical by md5.
Three checks failed on the first run and each was a real defect: a consent count
taken from a daily delta array instead of an instant, a worst-case figure computed
per stream where the SQL computed it per person (18, not 13), and a
counterfactual whose Python definition of “rebuild at send time” was not the one
the published SQL expressed. The SQL adjudicated all three.
Sources read rather than recalled: the GDPR text of Articles 5, 6, 7 and 30;
GoogleSQL’s query-syntax.md for the differential privacy clause (the ε/(n+1)
split, the max_groups_contributed default, the 1e20 escape); BigQuery’s
analysis-rules page for budget exhaustion and reset; BigQuery’s row-level-security
page for subquery support, SESSION_USER(), the pruning sentence and the
CREATE OR REPLACE TABLE behaviour; and the DifferentialPrivacyPolicy API
reference for epsilon_budget_remaining. This is a lesson about data engineering,
not legal advice; the statutory extracts are quoted so the engineering can be
checked against them.
Written with the help of AI (Claude) and reviewed by Rayhanul Islam.