Crosshire
← Handbook
EngagementDeliverables · owners · proof

20Written concepts

This route decides which documents the project owes — three, not eleven — who owns each one, and what proves it is still true. The distinguishing rule is that every concept names a test, so a document that has drifted from the warehouse turns a build red instead of sitting on a wiki being quietly wrong.

Stand 29.07.2026

What makes a concept binding

A DACH client asks for these by name — Entwicklerkonzept, Berechtigungskonzept, Anonymisierungskonzept — and usually receives PDFs that were accurate on the day they were signed. Two mechanics change that: a proof that runs, and a header that dates itself.

#A concept without a test is an opinion

What
A concept describes how the warehouse is supposed to behave. The warehouse behaves however the code and the grants say. Between the two there is nothing but goodwill — unless the concept names a query that fails when they diverge.
So every chapter making a checkable claim carries a file path. Three documents, three owners, three suites:
The three concepts and what proves each one
ConceptOwnerReviewersTimeboxExecutable proof
EntwicklerkonzeptLead engineerTwo engineers + platform3 days, then ½ day per quartertests/standards/ — identifier regex, layer read-direction, bundle validate
BerechtigungskonzeptData owner (business), drafted by the lead engineerSecurity/IT + DPO2 days, reviewed on every new schematests/access/ — SHOW GRANTS diffed against the concept's role matrix
AnonymisierungskonzeptDatenschutzbeauftragter (DPO)Lead engineer + legal2 days, reviewed on every new PII columntests/privacy/ — every pii-tagged column masked; no production-shaped data outside dwh_prod
sql
tests/access/grants_match_concept.sql — the permissions concept, executed. Zero rows means the document is still true.
-- The role matrix from chapter 3, checked into the repo as a VALUES list.
-- Symmetric EXCEPT: one direction finds grants nobody declared, the other
-- finds declarations nobody granted. One direction alone catches half.
--
-- Run this as a principal that can see every grant in the catalog.
-- information_schema is filtered to the objects the caller has privileges on,
-- so a narrowly-granted service principal reads an empty actual set, both
-- EXCEPTs collapse, and the check goes green having compared nothing.
WITH concept(grantee, table_name, privilege_type) AS (
  VALUES
    ('grp_dwh_bi_reader', 'mv_sales',          'SELECT'),
    ('grp_dwh_bi_reader', 'agg_revenue_month', 'SELECT'),
    ('grp_dwh_analyst',   'fct_order_line',    'SELECT'),
    ('grp_dwh_analyst',   'dim_customer',      'SELECT'),
    ('grp_dwh_engineer',  'fct_order_line',    'MODIFY')
),
actual AS (
  SELECT grantee, table_name, privilege_type
  FROM dwh_prod.information_schema.table_privileges
  WHERE table_schema = 'gold'
    AND grantee <> 'grp_dwh_owner'      -- ownership grants are implicit
)
SELECT 'undeclared' AS problem, * FROM (SELECT * FROM actual  EXCEPT SELECT * FROM concept)
UNION ALL
SELECT 'missing'    AS problem, * FROM (SELECT * FROM concept EXCEPT SELECT * FROM actual)
Why here
The failure mode is not a document that was wrong when written. It is one that was true — accurate in March, describing a warehouse that no longer exists in September. Concepts drift silently because nothing consumes them: code has tests, dashboards have users, a concept has a signature.
In a review — internal audit, a certification visit, the client's own security team — this is the difference between handing over a PDF and opening a pipeline run where the check is green. One is a claim about the warehouse; the other is the warehouse answering for itself.
Doing it
  • Write the proof before the chapter. If you cannot name the query that would fail, the chapter is a preference — label it as one rather than dressing it as a rule.
  • Run the three suites in the same CI job as the unit tests on every merge, and nightly against dwh_prod.
  • Link the proof by file path, not by description. A described test is one nobody can find.
  • Mark genuinely untestable chapters — escalation paths, team topology — as untestable in the text, so untested never means unnoticed.
Embedding it
  • Give each concept to a team member as owner, with you as reviewer. A document you wrote is a document they read once.
  • In the first review ask one question per chapter: which file fails if this stops being true? Silence is the finding — write it down and move on.
  • Have them break a proof deliberately: grant SELECT on gold.dim_customer to a group the matrix does not name, watch CI go red, revoke it. Five minutes, and the concept stops being paper.
Defaults
  • Three concepts, not eleven. Each extra document halves the chance any of them is read.
  • Markdown in the repository, changed by pull request, reviewed like code.
  • Every testable chapter names a path; every untestable chapter says so.
  • Proofs run against dwh_prod on a schedule, not only against dwh_test in CI. The drift you care about is in production.
Gotchas
  • A concept as a PDF on a file share. Nobody diffs a PDF, so the version the team reads and the version the auditor was given drift apart — discovered when both are on the same screen in a meeting you do not control.
  • Proofs that only run against dwh_test. dwh_test matches the concept because the bundle deploys it that way; dwh_prod has two years of hand-made grants that nothing checks. The green build measures the deployment, not the warehouse.
  • A proof that passes because it could not see anything. information_schema returns only the objects the caller has privileges on, so the grants check run as a minimally-granted service principal compares the concept against an empty set and goes green. Assert in the same test that the actual set is non-empty, or the only thing the proof proves is that the principal is narrow.
  • Writing the concepts at the end as handover artefacts. They then document what was built instead of governing it, and every decision they record was already unchangeable when it was written down.
  • One combined 'Data Platform Concept'. It gets a single owner, who is an engineer, so the data-protection chapters carry a signature from someone with no authority to give it — which is exactly the signature that gets asked for later.

#A header that dates itself

What
Every concept opens with the same machine-readable block. Not decoration: it is what makes a stale document announce itself instead of waiting to be discovered.
yaml
docs/concepts/berechtigungskonzept.md — the first lines
---
title:         Berechtigungskonzept - dwh_prod
owner:         A. Weber (Data Owner, Vertrieb)   # a named person, never a group
authors:       lead engineer + platform
last_reviewed: 2026-07-14
review_cycle:  90d
status:        active                      # draft | active | superseded
proof:         tests/access/grants_match_concept.sql
applies_to:    dwh_prod.gold, dwh_prod.silver
---
status: superseded earns its place: a replaced concept stays reachable with a pointer forward, because the question asked after an incident is what the rules were in March, not what they are today.
python
tests/docs/test_concept_headers.py — the whole check
import re
from datetime import date, timedelta
from pathlib import Path

import pytest
import yaml

CONCEPTS = sorted(Path("docs/concepts").glob("*.md"))


def test_concepts_are_found() -> None:
    """An empty parametrize list reports as skipped, not failed. Without this
    line the whole check passes vacuously the day the path or the CWD moves."""
    assert CONCEPTS, "docs/concepts/ is empty - nothing was checked"


@pytest.mark.parametrize("path", CONCEPTS, ids=lambda p: p.name)
def test_header_is_current(path: Path) -> None:
    front = re.match(r"^---\n(.*?)\n---", path.read_text(encoding="utf-8"), re.S)
    assert front, f"{path.name}: no header block"
    head = yaml.safe_load(front.group(1))

    if head["status"] != "active":
        return

    cycle = timedelta(days=int(head["review_cycle"].rstrip("d")))
    assert head["last_reviewed"] + cycle >= date.today(), (
        f"{path.name}: last reviewed {head['last_reviewed']}, cycle {head['review_cycle']}"
    )
    assert Path(head["proof"]).exists(), f"{path.name}: proof {head['proof']} is missing"
Why here
A concept's real failure mode is not being wrong. It is being trusted while wrong: someone designs a schema against chapter 3 of a document that stopped matching production two quarters ago, and the mistake is invisible because the document looked authoritative.
The header makes that state mechanical. Past the review cycle the build fails and the page says so on its own first line. The owner field does the other half — a document owned by 'the team' is owned by nobody, and its review is nobody's Tuesday afternoon.
Doing it
  • Put the header on all three concepts in week one, while the bodies are still stubs.
  • Name a person as owner and a group as reviewers. Reversed, nothing happens.
  • Set cycles the team will honour — 90 days for permissions, 180 for the developer concept — and shorten a cycle rather than skip a review.
  • Ship the check as a warning first and a hard failure from week six, so it lands as a habit rather than an obstacle.
Embedding it
  • Ask the owner to run the review in a 30-minute slot with the quarter's schema diff open. Reviewing against nothing reliably produces 'still fine'.
  • When a review changes nothing, ask what changed in the warehouse that quarter. Something did — that gap is the coaching moment, not the document.
  • Let the team choose the review cycles. They defend numbers they picked.
Defaults
  • One machine-readable header at the top of every concept.
  • Named human owner, group reviewers, both in the header.
  • Supersede, never delete.
Gotchas
  • Review dates bumped without a review. It is one line in a diff and looks identical to real work. Countermeasure: a review lands as a commit that also touches the body or the proof, so a lone date change is one grep away from being spotted.
  • Deleting a superseded concept. The question after an incident is what the rules were at the time, and that answer now lives only in git history nobody will reconstruct at 22:00 during an escalation.
  • review_cycle stretched to 365d so the check stops firing. It stops firing because the document is never reviewed, which was the thing being measured. It shows up as a one-line diff on a red build and is approved in seconds, so make the cycle itself reviewable: the only value the check accepts without an argument is the one the team wrote down in week one.

The three documents

Each has a table of contents you can copy. Fixing the contents in advance means the argument about scope happens once, in week one, instead of in every review for the rest of the project.

#Entwicklerkonzept — how this team builds

What
The developer concept answers one question: how does work get built here. It is the shortest of the three, because most chapters link into this handbook rather than restating it.
text
Entwicklerkonzept — full table of contents
owner:     lead engineer
reviewers: two engineers + platform/architecture
timebox:   3 days initial, half a day per quarter
proof:     tests/standards/

1  Scope and non-goals
2  Architecture
   2.1  Layer contract: what may enter and leave bronze / silver / gold
   2.2  Topology: dwh_dev / dwh_test / dwh_prod, layers as schemas
3  Modelling
   3.1  Star schema in gold; silver stays source-shaped
   3.2  Surrogate keys: sha2 over normalised business keys
   3.3  SCD2 interval semantics, exclusive end, the UNKNOWN member
4  Naming
   4.1  Prefixes: dim_ fct_ agg_ brg_ stg_ mv_ _tech_
   4.2  ^[a-z][a-z0-9_]*$, ASCII only; reserved words (sales_order, not order)
5  Code structure
   5.1  The transform is a pure function; IO at the edges
   5.2  Module layout under src/dwh/
   5.3  Workspace files and the sys.path bootstrap — no wheel
6  Testing
   6.1  The four kinds: PySpark unit, SQL assertion, pipeline, warehouse invariant
   6.2  What every merge runs; what runs nightly
7  Delivery
   7.1  Bundle targets dev / test / prod; bundle validate first in CI
   7.2  Branching, review rules, OAuth service principals
8  Definition of done
9  Not testable: on-call rota, escalation, team topology
Chapters 2 to 7 are one to two pages each. Chapter 8 is the one that actually gets read, so it is written as conditions a reviewer can check without judgement:
  • Grain sentence in the table COMMENT, deployed in DDL.
  • A uniqueness assertion and an orphan assertion exist in tests/sql/ for every new gold table.
  • The transform is importable and tested with no pipeline decorator in the call path.
  • bundle validate passes on the test target.
  • Identifiers match the regex; no new reserved word used unquoted.
  • The runbook entry answers the re-run question: safe blind, or intervention first.
Why here
It stops the same three arguments recurring: where does this table belong, what is it called, does it need a test. Twenty minutes each, in every review, for the life of the project. A written answer turns an argument into a lookup a junior reviewer can perform on a senior engineer's pull request.
The second payoff is onboarding. A new engineer with this document and the canonical model contributes within a week; without it the tacit rules are learned one rejected pull request at a time, and each rejection costs two people a day.
Doing it
  • Draft it in week one, before there are tables to argue about, and keep it near fifteen pages.
  • Link out to docs.databricks.com for anything generic — the concept records decisions, not platform behaviour.
  • Write chapter 8 so that at least one recent pull request would have failed it. A definition of done nothing has ever failed is a description, not a gate.
  • Land each rule and its CI check in the same pull request, so no rule is merged unenforced.
Embedding it
  • Have two engineers draft chapters 3 and 4 and defend them to the rest of the team. The defence is where the modelling disagreements surface.
  • Ask the newest joiner to list what they still had to ask a colleague after reading it. That list is the next revision.
  • When a review comment repeats itself for the third time, stop commenting and make the team add it to chapter 8.
Defaults
  • Fifteen pages: links out, decisions in.
  • Every rule in chapters 4 to 7 has a CI check, or is marked as a convention.
  • Chapter 8 is checkable by someone who was not in the room when the rule was made.
  • Concept and code live in one repository and move in one pull request.
Gotchas
  • A developer concept that restates platform documentation. It reaches sixty pages, nobody reads past chapter 2, and the project's own decisions are buried behind material that is a search away and better maintained.
  • A definition of done written so it cannot fail. 'Code is tested' passes everything; 'every new gold table has a grain comment and a uniqueness assertion in tests/sql/' fails something this week.
  • Writing chapter 5 without chapter 6. Structure decisions not made for testability produce a document that mandates pure functions over a codebase where every transform sits behind a decorator — untrue on the day it ships.
  • Reusing the last client's document with a find-and-replace on the name. It survives until someone asks why the concept describes a Data Vault this warehouse does not have, and after that no chapter of it is trusted.

#Berechtigungskonzept — access as policy, not a spreadsheet

What
This is the document a German client asks for by name, often before the first table exists, and usually the one that stalls — because it gets written as a matrix of individual grants that is out of date the day it is signed.
text
Berechtigungskonzept — full table of contents
owner:     data owner (Vertrieb) - a named person, drafted by the lead engineer
reviewers: Security/IT, Datenschutzbeauftragter
timebox:   2 days initial, reviewed on every new schema
proof:     tests/access/

1  Scope: which catalogs, and what is explicitly out of scope
2  Principals
   2.1  Groups only - no grant to a named user, ever
   2.2  One service principal per job; OAuth, never personal access tokens
   2.3  Group membership: source of truth in the IdP, who approves a join
3  Role matrix
   3.1  grp_dwh_engineer / grp_dwh_analyst / grp_dwh_bi_reader / grp_dwh_owner
   3.2  gold granted per object; silver by exception; bronze engineers only
4  Attribute-based access
   4.1  Governed tags: pii, classification, domain, environment
   4.2  Column mask policies attached to tags, not to tables
   4.3  Row filter on country, driven by the group's region attribute
5  Environment isolation
   5.1  Workspace-catalog binding: a dev workspace cannot reach dwh_prod
   5.2  Who may create external locations (a very short list)
6  Ownership: production objects owned by groups, never individuals
7  Review
   7.1  Quarterly access review over pii-tagged tables - the query, and who signs
   7.2  Alerting on permission changes in dwh_prod
8  Exceptions register: who, what, and until when
Chapter 4 is what keeps the document short, and it is deployed rather than described:
sql
The concept's chapter 4.1, executed as DDL
-- "email, phone and street are personal data" - that sentence, as three
-- statements. The mask policy hangs off the tag, so a column added to any
-- table next quarter is protected the moment it is tagged.
--
-- Precondition: pii must already exist as a GOVERNED tag defined at account
-- level. Run these before that and they still succeed - they just create an
-- ordinary free-form tag, which no ABAC policy can attach to. The columns
-- then look tagged in Catalog Explorer and are masked by nothing.
ALTER TABLE dwh_prod.gold.dim_customer ALTER COLUMN email  SET TAGS ('pii' = 'true');
ALTER TABLE dwh_prod.gold.dim_customer ALTER COLUMN phone  SET TAGS ('pii' = 'true');
ALTER TABLE dwh_prod.gold.dim_customer ALTER COLUMN street SET TAGS ('pii' = 'true');
Why here
The failure mode is the grant spreadsheet: 140 rows of principal × object, correct on the day of sign-off, decaying from the first new table onward. Two quarters later nobody can say whether it describes production, so the access review consists of reading the spreadsheet — reviewing the document against itself.
Written as attributes, the same document is one page and stays true. Four groups, four tags, three policies, and every table created afterwards is covered the moment it is tagged — including the tables nobody has designed yet.
Doing it
  • Write the role matrix at the level of four groups and gold as a whole; grant object-by-object only where a real exception exists.
  • Define governed tags at account level before writing chapter 4 — a tag invented inside one schema cannot carry a policy across the catalog.
  • Attach masks to tags and tag dim_customer.email, .phone and .street; never write a per-table mask you must remember to repeat.
  • Give the exceptions register an expiry column and let entries expire — an exception with no end date is a rule change nobody reviewed.
Embedding it
  • Have the data owner, not the engineer, present chapter 3 to Security. The concept is theirs the moment they have defended it.
  • Let a new table go live untagged once in dwh_test and have them find it with the proof query. The gap between 'we tag things' and 'tagging is enforced' becomes concrete in one run.
  • Put the quarterly access review in the team's calendar with a named signer before you leave, not in the handover deck.
Defaults
  • Groups only. A grant to a person is a finding, not a shortcut.
  • Policy on tags; per-table masks only where a tag genuinely cannot express the rule.
  • Binding on top of grants, so isolation is structural rather than a grant nobody has issued yet.
  • One page of matrix, one page of policy, one register of exceptions with dates.
Gotchas
  • The grant spreadsheet. You notice it has failed when SHOW GRANTS and the sheet disagree and nobody can say which is authoritative — usually during the audit that asked for both.
  • 'Temporary' grants to individual users. They outlive the role and often the person. The tell is a grantee in information_schema.table_privileges that looks like an email address rather than a group name: one query, run monthly, and every hit is a real finding.
  • Believing a masked column is a denied column. An excluded user can still CLONE the table and TIME TRAVEL it, so the concept must distinguish what is denied from what is merely hidden — otherwise it promises something the platform does not do.
  • Signing the concept before workspace-catalog binding exists. Chapter 5 is then aspirational, and it is discovered by the first person who attaches a development cluster to dwh_prod because nothing stopped them.

#Anonymisierungskonzept — and the word that is usually wrong

What
Which data is personal, what is done to it, and what happens when a person asks to be erased. Owned by the DPO, drafted by the lead engineer — that split is the point, not bureaucracy.
text
Anonymisierungskonzept — full table of contents
owner:     Datenschutzbeauftragter
reviewers: lead engineer, legal
timebox:   2 days initial, reviewed on every new PII column
proof:     tests/privacy/

1  Legal basis and scope: which processing, which catalogs
2  Inventory of personal data
   2.1  gold.dim_customer.email / .phone / .street - directly identifying
   2.2  country + postal_code + customer_segment - quasi-identifiers
   2.3  Keeping the inventory current: automated classification + governed tags
3  Techniques and what each one legally achieves
   3.1  Pseudonymisation: hashing, tokenisation, masking - still personal data
   3.2  Anonymisation: aggregation above a k-threshold, generalisation, suppression
   3.3  Where each is used in this warehouse, and where neither is
4  Test data
   4.1  No production copy in dwh_dev or dwh_test, in any form
   4.2  Synthetic generation from the factories, referential integrity preserved
   4.3  The check that proves it
5  Access: masks, row filters, and the one group that sees unmasked values
6  Erasure requests
   6.1  Finding every SCD2 version of a subject in gold.dim_customer
   6.2  What is erased, and what is retained under a statutory obligation
   6.3  Deletion vectors, VACUUM, and the moment the bytes actually go
7  Deletion from bronze - the hard part, because bronze is append-only by contract
8  Sharing: Delta Sharing, never a CSV export
The direct consequence here: gold.dim_customer.email hashed into dwh_dev is a copy of production personal data wearing a costume. Not out of scope, not exempt from erasure, and not lawful because it looks like hexadecimal.
Why here
Two failures, both expensive. The first is a development catalog seeded from a production export because 'the emails are hashed' — an incident no test will surface, found instead by a classification scan or by a subject access request that has to be answered honestly.
The second is the erasure request arriving in month nine, when nobody has decided what erasure means against an SCD2 dimension holding eight versions of one customer and a fact table pointing at all of them. That decision takes an afternoon in week two and a fortnight in month nine, against a statutory clock measured in days.
Doing it
  • Generate chapter 2 from the governed tags rather than typing it — a hand-maintained inventory is wrong within a quarter, and wrong in the direction that matters.
  • Decide erasure semantics against gold.dim_customer while it holds eight rows, not eight million: which versions are overwritten, which columns survive, what the fact rows point at afterwards.
  • Name the retention obligation that overrides erasure and the columns it covers, sourced from the client's legal team — never from an engineer's memory of a paragraph number.
  • Route every external hand-off through Delta Sharing. A CSV leaves the platform and takes its masks, its lineage and its erasure reachability with it.
Embedding it
  • Have the engineer who believes hashing anonymises demonstrate the opposite: hash a thousand plausible addresses, join to the hashed column, count the matches. Four minutes, and the argument never comes back.
  • Make the DPO the owner and mean it — the engineer drafts, the DPO signs. A privacy document owned by engineering is an engineering opinion with a stamp on it.
  • Run one erasure request as a timed drill on a seeded subject in dwh_test before a real one arrives. The number you get is what the business is told.
  • Treat 'can we copy prod to reproduce this bug' as the standing test of whether the concept is real. It is asked about once a quarter.
Defaults
  • The inventory is generated from tags, never typed.
  • Write pseudonymisation when you mean pseudonymisation. Never write 'anonymised' about a hash.
  • Erasure semantics decided in week two and drilled once before they are needed.
  • Delta Sharing out. No CSV, no ad-hoc extract, no 'just this once'.
Gotchas
  • 'It's hashed, so it's anonymised.' It is pseudonymised: still personal, still in scope, still erasable — and a hashed email is directly reversible against a candidate list, so the claim is wrong in practice as well as in law.
  • Erasing from gold and forgetting bronze. Bronze is append-only by contract and holds every historical extract of sap_kna1, so the subject is still there in full and the erasure confirmation you signed is false. Decide bronze retention in the concept or the concept does not close.
  • Running DELETE and considering it done. Delta writes a deletion vector; the rows stay in the underlying files and stay readable via TIME TRAVEL until VACUUM passes the retention window. 'When did the data actually go' is the question you will be asked — answer it in chapter 6.3.
  • Ignoring quasi-identifiers. On a B2B distributor, country + postal_code + customer_segment isolates a large share of rows by itself, so a table with the name column dropped is not therefore anonymous. Chapter 2.2 exists to say so before someone exports one.

The chapter that gets violated

Test data is one chapter of the anonymisation concept and the one most likely to be broken by a well-meaning engineer at 17:00 on a Thursday. It gets its own page for that reason.

#Never a production copy in dev

What
The rule is one sentence: no production data in dwh_dev or dwh_test — not sampled, not hashed, not 'only the one table'. The rest of the chapter is the machinery that lets the rule survive contact with a real bug.
What replaces it is the factory the PySpark tests already use, scaled up and run as a seed job into dwh_test.fixtures:
python
tests/factories.py — the customer factory, next to the order_line_rows the PySpark route already defines
from typing import Any

from pyspark.sql.types import StringType, StructField, StructType

# RFC 2606 reserves example.com, so a stray test mail can never reach an inbox.
# The national regulator reserves a number block per area code for drama and
# testing. Both live in one constant each: that is what lets the privacy proof
# below stay two LIKEs instead of a regex nobody maintains.
TEST_DOMAIN = "example.com"
DRAMA_BLOCK = "+49 30 23125"

CUSTOMER_SCHEMA = StructType([
    StructField("customer_id",      StringType(), False),
    StructField("customer_name",    StringType(), False),
    StructField("email",            StringType(), True),
    StructField("phone",            StringType(), True),
    StructField("street",           StringType(), True),
    StructField("postal_code",      StringType(), True),
    StructField("city",             StringType(), True),
    StructField("country",          StringType(), False),
    StructField("customer_segment", StringType(), False),
])

# A boring customer: country in scope, segment set, contact details
# unreachable by construction. Each argument to the factory is a delta from it.
_CUSTOMER: dict[str, Any] = {
    "customer_id":      "C-0001",
    "customer_name":    "Muster Handel GmbH",
    "email":            f"kunde0001@{TEST_DOMAIN}",
    "phone":            f"{DRAMA_BLOCK}001",
    "street":           "Teststrasse 1",
    "postal_code":      "10115",
    "city":             "Musterstadt",
    "country":          "DE",
    "customer_segment": "B",
}


def customer_rows(*overrides: dict[str, Any]) -> list[dict[str, Any]]:
    """One row per argument; each argument is the delta from a valid customer."""
    return [{**_CUSTOMER, **o} for o in (overrides or ({},))]
python
tests/seed_fixtures.py — the same factory, scaled up; the catalog is a parameter, never a literal
import random

from tests.factories import CUSTOMER_SCHEMA, DRAMA_BLOCK, TEST_DOMAIN, customer_rows

COUNTRIES = ["DE", "AT", "CH", "NL"]     # business rule 3
SEGMENTS = ["A", "B", "C"]


def seed_customers(spark, catalog: str, n: int, seed: int = 42) -> int:
    """Seed <catalog>.fixtures.customer from the unit tests' own factory.

    Returns rows written. Only ever pointed at dwh_test.
    """
    rnd = random.Random(seed)
    rows = customer_rows(*(
        {
            "customer_id":      f"C-{i:05d}",
            "customer_name":    f"Muster Handel {i:05d} GmbH",
            "email":            f"kunde{i:05d}@{TEST_DOMAIN}",
            "phone":            f"{DRAMA_BLOCK}{i % 1000:03d}",
            "street":           f"Teststrasse {i % 200 + 1}",
            "postal_code":      f"{rnd.randrange(10_000, 99_999)}",
            "country":          rnd.choice(COUNTRIES),
            "customer_segment": rnd.choice(SEGMENTS),
        }
        for i in range(n)
    ))

    # Explicit schema, as everywhere else: inference is how a test ends up
    # exercising a type the warehouse does not have.
    df = spark.createDataFrame(rows, CUSTOMER_SCHEMA)
    df.write.mode("overwrite").saveAsTable(f"{catalog}.fixtures.customer")
    return df.count()
sql
tests/privacy/no_production_data_outside_prod.sql — zero rows means pass
-- Every address in a non-production catalog must sit on the reserved test
-- domain and every number in the regulator's drama block. Anything else is a
-- production copy or a hand-typed real customer, and both are the same finding.
--
-- NULL is a finding too, deliberately. The factory always fills both columns,
-- so a NULL here means either a row nobody generated or a column mask standing
-- between this principal and the data. A mask that returns NULL would
-- otherwise empty the result and let the check pass having read nothing.
--
-- The two literals below repeat TEST_DOMAIN and DRAMA_BLOCK from
-- tests/factories.py. Change one and this goes red, which is the intent.
SELECT 'dwh_dev' AS catalog_name, customer_id, email, phone
FROM dwh_dev.gold.dim_customer
WHERE email IS NULL OR email NOT LIKE '%@example.com'
   OR phone IS NULL OR phone NOT LIKE '+49 30 23125%'
UNION ALL
SELECT 'dwh_test' AS catalog_name, customer_id, email, phone
FROM dwh_test.gold.dim_customer
WHERE email IS NULL OR email NOT LIKE '%@example.com'
   OR phone IS NULL OR phone NOT LIKE '+49 30 23125%'
Why here
The request always arrives in the same shape — we cannot reproduce this without real data, just this once, just this table — and it is a reasonable request from a good engineer, which is exactly why it succeeds. A copy taken once exists forever, in a catalog with development grants, inside whatever backup schedule dev happens to have.
The second argument persuades engineers rather than lawyers: production data is poor test data. It contains no CANCELLED line with a negative amount, no customer whose segment changed twice on one day, no order dated 2019. Those are precisely the rows the assertions need, and a factory can be told to produce them.
Doing it
  • Seed dwh_test.fixtures from the factories before the suite runs, and drop the per-run schema in a step that executes even when the tests failed.
  • Give every generated address the reserved domain and every generated number the regulator's drama block, so the proof stays two LIKEs rather than a regex nobody maintains.
  • When a bug genuinely needs production characteristics, copy the shape and not the rows: profile row counts, cardinalities and null rates, then reproduce that distribution synthetically.
  • One factory module, imported by both the unit tests and the seed job. Two generators means two sets of fixtures, and the suite then green-lights a shape the seed job cannot actually write.
  • Give each row-level business rule a named override — a country outside DE/AT/CH/NL (rule 3), a zero unit_price on a non-cancelled line (rule 5), an order dated 2019 (rule 6), a customer_id no dimension row resolves (rule 7) — so no assertion is written without a row that fails it.
Embedding it
  • The first time someone asks for a production copy, do not refuse — pair for thirty minutes and build the failing row synthetically. They are faster on the second bug and stop asking by the fourth.
  • Have the team add every production incident's shape to the factory as a named builder. The factory becomes the team's museum of things that actually went wrong.
  • Ask in review where the test data came from. It is a one-word answer and a very strong signal.
Defaults
  • One factory module, seeded per run, dropped per run.
  • Reserved test domain on every address and the regulator's drama block on every number, so the check stays two LIKEs.
  • Edge cases generated deliberately, one per business rule.
  • The privacy proof runs nightly against every non-production catalog, not only in CI.
Gotchas
  • 'Just one table, and it's hashed.' Hashing is pseudonymisation: it is still production personal data in a catalog with development grants, and the anonymisation concept has just been made false in writing.
  • The one-off copy taken during go-live weekend. Nothing about it is one-off — it is found two years later by a classification scan, in a schema whose owner has left, and it has been in every dev backup since.
  • Synthetic data with only the happy path. The suite is green, and the first CANCELLED line with a negative amount reaches gold in production because no test ever saw one.
  • Factories that drift from the schema. The generator emits a column the table dropped, the seed job dies at 03:00, and the failure reads as a pipeline bug for the first hour. Assert the factory's keys against the target table's columns in a three-line test and it fails at merge instead.

Straight to the documentation

The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.