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.
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
| Concept | Owner | Reviewers | Timebox | Executable proof |
|---|---|---|---|---|
| Entwicklerkonzept | Lead engineer | Two engineers + platform | 3 days, then ½ day per quarter | tests/standards/ — identifier regex, layer read-direction, bundle validate |
| Berechtigungskonzept | Data owner (business), drafted by the lead engineer | Security/IT + DPO | 2 days, reviewed on every new schema | tests/access/ — SHOW GRANTS diffed against the concept's role matrix |
| Anonymisierungskonzept | Datenschutzbeauftragter (DPO) | Lead engineer + legal | 2 days, reviewed on every new PII column | tests/privacy/ — every pii-tagged column masked; no production-shaped data outside dwh_prod |
-- 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)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.
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
---
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.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"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.
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
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- 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.
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.
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
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-- "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');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.
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
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 exportgold.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.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.
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
dwh_test.fixtures: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 ({},))]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()-- 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%'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.
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.
- Unity Catalog privileges and securable objects — the authoritative privilege list — chapter 3 of the permissions concept must be written in these words, not in invented ones
- Attribute-based access control in Unity Catalog — chapter 4 in the platform's own terms; check the GA scope before you promise a policy shape
- information_schema reference — where the grant proof reads from — table_privileges is the view you want, and it is per-catalog
- Remove unused data files with VACUUM — read before writing the erasure chapter: it says when the bytes actually leave, which is not when DELETE returns
- Delta Sharing — the alternative the anonymisation concept mandates instead of a CSV export