Crosshire
← Handbook
FoundationKimball · Vault · keys · SCD2

02Data model

This route decides what shape gold takes, how a surrogate key is produced, and what history means here. All three are baked into rows: the key recipe is written into every fact ever loaded, and the interval convention into every query that asks what was true last March. Changing either later is a re-key of the warehouse plus a cut-over of everything that stored a key.

Stand 29.07.2026

Choosing the shape

Three modelling styles get proposed in the first workshop. Only one is what a serving layer is for, and the argument you need is not that the other two are wrong.

#Kimball, Data Vault, or one wide table

What
Gold here is a Kimball star: conformed dimensions with surrogate keys, facts at the finest grain the source delivers, aggregates derived from facts. A decision, not a default — and it gets challenged, usually by someone who has delivered Data Vault well.
The three candidates, judged as a serving layer
Kimball starData Vault 2.0One wide table
Core unitdim_ and fct_hub / link / satelliteone denormalised table per subject
Optimised forreading and aggregatingloading and auditingone dashboard
Objects here6 tables + 1 metric viewroughly three times thatone per report
History livesSCD2 in the dimensionsin satellites, every attribute, alwaysfrozen into each row
A new source costsmapping into the conformed dimensiona new satellite; nothing existing changesa rebuild
Analyst self-serviceyesno — a Vault is not a serving layeryes, until the second question
The vocabulary, which you need to use correctly to be taken seriously: a hub is a business key and its hash. A link is a relationship between hubs, carrying no descriptive data. A satellite holds the attributes of a hub or link with a load timestamp, insert-only.
text
Illustration only — these Vault objects are NOT part of this warehouse
this warehouse, as a star        the same domain, as a Data Vault
------------------------------   -------------------------------------
gold.dim_customer   (SCD2)       hub_customer + sat_customer_sap
                                              + sat_customer_shop
gold.dim_product    (SCD2)       hub_product  + sat_product
gold.fct_order_line              hub_sales_order + lnk_order_line
                                              + sat_order_line
gold.agg_revenue_month           still to be built, on top of all of it
Why here
The failure this prevents is not choosing badly; it is choosing twice. A Vault and then a star means two definitions of a customer — discovered four months in, when the extract and the dashboard disagree by 0.4 % and nobody can say which is right. The wide-table version arrives faster: with no conformed customer to group by, the second report joins on customer_name, and DE/AT spellings of one company do the rest.
Doing it
  • Write the choice down in week one with its rejected alternatives; unwritten, it is re-opened every sprint.
  • If a Vault exists upstream, treat it as silver and build gold on top. Proposing its removal is an argument you will lose, and should.
  • Model the star from the questions the business asks. Anything nobody groups by is not a dimension.
  • Facts at source grain; agg_ tables derive from fct_, never from silver.
Embedding it
  • Have them model one new source into the star while you review rather than draw — the webshop forces the conformance question immediately.
  • Run the Vault comparison with the Vault advocate presenting. They make the case better than you and own the outcome either way.
  • Ask in every design review which question the new table answers.
Defaults
  • Kimball star in gold; silver stays source-shaped and historised.
  • One decision record: the choice, the alternatives, the date, the author.
  • Facts at the finest grain delivered; aggregates derived, never authored.
  • If a Vault exists upstream, it is silver. Do not build a second integration layer.
Gotchas
  • Running a Vault and a star as peers. Two revenue definitions, found when an extract and a dashboard differ by a fraction of a percent — a week of arbitration each time, recurring.
  • Adopting Data Vault for five sources. Object count triples and the first business-visible number arrives a month later. It earns its cost at twenty sources with provenance obligations.
  • Wide tables with dimension attributes copied in. Restating history means rewriting the table, so nobody does, and the trend chart applies today's segment to last year's revenue.

Keys and constraints

How a surrogate key is produced, and what a declared primary key actually does on this platform. The second one surprises experienced people.

#Hash keys, and the separator that saves you

What
Every gold surrogate key is a SHA-256 over the normalised business key. Not an identity column.
sql
The two key recipes in this warehouse — shorthand
-- gold.dim_customer
sha2(concat_ws('||', customer_id), 256)              AS customer_sk

-- gold.fct_order_line
sha2(concat_ws('||', order_id, line_number), 256)    AS order_line_sk

-- Shorthand: both lines omit the normalisation. Nothing in this warehouse
-- builds a key from them. make_sk() below is the executable form, and a key
-- hashed without trim/upper/coalesce does not equal the one already stored
-- in the fact — the join returns nothing and raises nothing.
Determinism is the reason. A hash key is computable on the fact side without reading the dimension, so fct_order_line and dim_customer build in parallel, and a full rebuild reproduces identical keys — which is what makes the idempotency test possible. Identity values depend on write order, so a backfill renumbers the dimension while every stored extract keeps the old numbers.
The separator is not decoration. Concatenate without one and two different keys collapse into one hash:
sql
Run this. It returns true.
SELECT sha2(concat('4711', '2'), 256)
     = sha2(concat('471', '12'), 256)  AS collides;
-- true — both hash the string '47112'.
--
-- Order 4711 line 2 and order 471 line 12 now share one order_line_sk.
-- Nothing errors. The dimension join fans out and one line's revenue
-- is attributed to the other.
concat_ws fixes that and introduces the second landmine: it silently skips NULLs, so concat_ws('||','A',NULL,'B') and concat_ws('||','A','B') are one string and a key with a missing middle component collides with a shorter one. Coalesce first, to a sentinel that cannot occur in the data.
python
src/dwh/common/keys.py — the only place a key is ever built
from pyspark.sql import Column, functions as F

SEPARATOR = "||"
NULL_SENTINEL = "~"


def make_sk(*columns: str) -> Column:
    """Deterministic surrogate key: trim, upper, coalesce, join, sha2-256."""
    parts = [
        F.coalesce(F.upper(F.trim(F.col(c).cast("string"))), F.lit(NULL_SENTINEL))
        for c in columns
    ]
    return F.sha2(F.concat_ws(SEPARATOR, *parts), 256)
' 4711' and '4711' are one customer to a human and two to SHA-256, and leading whitespace out of a fixed-width SAP extract is not hypothetical. Check the source before applying upper, though: it silently merges keys differing only in case — right for SAP, wrong for a case-sensitive system.
Why here
A surrogate key is written into every fact row ever loaded, so changing the recipe afterwards is not a refactor — it is re-keying the warehouse in one transaction with every consumer that stored a key. And both failure modes are silent: a collision does not raise, it produces a fact row that joins to the wrong dimension member. The revenue is not missing, it is somewhere else, and you find out when a customer disputes an invoice.
Doing it
  • One helper, one module. An inline sha2 expression in a model file is the copy that will diverge.
  • Pick a separator that cannot occur in a business key, and add an ingestion expectation rejecting keys that contain it.
  • Pin the recipe with a test containing hand-computed hashes — it stops a well-meant cleanup from changing it.
  • Keep the business key visible beside the _sk; you cannot reverse a hash for a support question.
Embedding it
  • Make them produce the collision themselves in a notebook. Two minutes, and nobody on that team writes concat into a key again.
  • In review, ask where the key is built. If the answer is an expression in the model rather than make_sk, let them name the finding.
  • Hand them the versioning problem: how would we change the separator next year? They arrive at 'not cheaply', which is the lesson.
Defaults
  • sha2(..., 256) over the normalised business key, stored as STRING.
  • trim, upper, coalesce, concat_ws, sha2 — in that order, in one function.
  • Identity columns only where the value is never referenced outside the table, which in a warehouse is almost nowhere.
  • Never truncate the hash.
Gotchas
  • concat instead of concat_ws. Order 4711 line 2 and order 471 line 12 hash identically, the fact gets a duplicate key, and revenue moves between customers with no error anywhere.
  • concat_ws skipping NULLs. A key with a missing middle component hashes like a shorter key, so two dimension members merge into one. Row counts stay plausible; the customer is wrong.
  • Truncating the hash to eight hex characters. That leaves 32 bits — a coin-flip chance of collision at about 77,000 keys, and dim_customer will pass that.
  • Identity columns plus a full refresh. The dimension renumbers, the fact keeps the old numbers, nothing fails, and the joins now return different customers than yesterday.

#Constraints that do not constrain

What
Unity Catalog accepts PRIMARY KEY and FOREIGN KEY declarations on Delta tables. They are informational metadata. Nothing is checked, on write or ever.
sql
Declared, violated, accepted — in that order
-- every column of a primary key must ALREADY be NOT NULL,
-- or the ADD CONSTRAINT is rejected before it declares anything
ALTER TABLE gold.dim_customer ALTER COLUMN customer_sk      SET NOT NULL;
ALTER TABLE gold.dim_customer ALTER COLUMN _tech_valid_from SET NOT NULL;

-- dim_customer is SCD2, so the key is the VERSION, not the customer
ALTER TABLE gold.dim_customer
  ADD CONSTRAINT pk_dim_customer
  PRIMARY KEY (customer_sk, _tech_valid_from);

-- now insert the same version twice
INSERT INTO gold.dim_customer
  (customer_sk, customer_id, customer_name, customer_segment, country,
   _tech_valid_from, _tech_valid_to, _tech_is_current, _tech_hash)
VALUES
  ('a1b2', 'C-4711', 'Muster GmbH', 'A', 'DE',
   TIMESTAMP '2026-07-01 00:00:00', NULL, true, 'h1'),
  ('a1b2', 'C-4711', 'Muster GmbH', 'B', 'DE',
   TIMESTAMP '2026-07-01 00:00:00', NULL, true, 'h2');

-- 2 rows inserted. No error. The primary key is still declared.
RELY is where it gets interesting: it tells the optimizer to trust the declaration, permitting rewrites such as eliminating a join to a dimension whose columns are not selected. The platform still verifies nothing, so the duplicate above produces a plan built on a false premise. The violation returns wrong results faster.
Two Delta constraint types are enforced at write time, and that is where the effort belongs:
sql
Business rules 3 and 5, enforced by the engine rather than by hope
ALTER TABLE gold.dim_customer
  ADD CONSTRAINT country_domain
  CHECK (country IN ('DE', 'AT', 'CH', 'NL'));

ALTER TABLE gold.fct_order_line
  ADD CONSTRAINT amount_positive
  CHECK (status = 'CANCELLED' OR amount_eur > 0);

ALTER TABLE gold.fct_order_line
  ALTER COLUMN order_line_sk SET NOT NULL;

-- not a business rule: a NULL _tech_hash makes the SCD2 MERGE's
-- tgt._tech_hash <> src._tech_hash comparison NULL, so that customer
-- silently stops versioning. NOT NULL is the cheapest place to stop it.
ALTER TABLE gold.dim_customer
  ALTER COLUMN _tech_hash SET NOT NULL;
Adding a CHECK validates existing rows first, so the ALTER fails on a table that already violates it — a free audit of data you believed was clean, and a two-hour surprise if you meet it inside a release window.
Why here
Teams read a declared primary key as a guarantee and stop testing for duplicates. The duplicate then arrives from a source that delivered one customer twice in one extract, and the first symptom is revenue three percent high because the dimension join fanned out.
Declare them anyway: they document the model, BI tools infer relationships from them, and RELY genuinely helps the optimizer when the constraint holds. Documentation with a performance side effect is fine — as long as nobody mistakes it for enforcement.
Doing it
  • Declare PK and FK wherever they are true. They cost nothing and are the model's most-read documentation.
  • Add RELY only where a test proves the constraint on every run, and name that test in the decision record.
  • Put the rules that must stop a load into CHECK constraints; the rest go in the SQL assertion suite.
  • Get the SCD2 primary key right: (customer_sk, _tech_valid_from), never customer_sk alone.
Embedding it
  • Run declare-then-insert-duplicate in front of the team. Ninety seconds, and it is the only way the belief actually dies.
  • Ask which test backs each RELY in the repo. No test, no RELY — and let them apply that to each other rather than to you.
  • Have them decide per business rule whether it belongs in a CHECK (the load fails at 03:00) or an expectation (the row is quarantined). Owning that trade-off beats inheriting a default.
Defaults
  • PK and FK for documentation; uniqueness and orphans enforced by tests that run every build.
  • RELY only behind a passing test.
  • NOT NULL and CHECK for the invariants that should stop a load.
  • The primary key of an SCD2 dimension includes the interval start.
Gotchas
  • PRIMARY KEY (customer_sk) on an SCD2 dimension. Wrong from the second version row onward, nothing complains, and it survives to become the ER diagram everyone trusts.
  • Declaring the primary key before the columns are NOT NULL. Databricks rejects the ADD CONSTRAINT outright, so you notice this one immediately — but it lands in the deployment script, not the notebook, which means it fails in the release pipeline at the point where the table already exists and the rollback story is unwritten. Set NOT NULL in the CREATE TABLE.
  • A CHECK constraint whose expression evaluates to NULL. NULL is not false, so the row is accepted: CHECK (amount_eur > 0) passes every row where amount_eur is NULL, which is exactly the population you added the constraint for. Pair every CHECK on a nullable column with NOT NULL, or write the check as amount_eur IS NOT NULL AND amount_eur > 0.
  • RELY on an untested constraint. The optimizer drops a join, the query gets measurably faster, and the wrong number arrives sooner. An unexplained speed-up after adding RELY is a symptom, not a win.
  • Joining fct_order_line to dim_customer on customer_sk alone. A customer with three versions triples that customer's fact rows; revenue is overstated by an amount small enough to survive review.
  • Believing a declared FK rejects orphans. Nothing rejects anything — the orphan assertion in the SQL testing route is the enforcement; unwritten, orphans are simply absent from every inner-joined report.

History

SCD2 is four columns and a MERGE, plus about six ways to be subtly wrong. The interval convention removes three of them; the injected clock removes another.

#SCD2: half-open intervals and the injected clock

What
One convention, everywhere: [_tech_valid_from, _tech_valid_to). The end is exclusive, and NULL means open.
text
One customer, one segment change, two rows
customer_sk  segment  _tech_valid_from     _tech_valid_to       _tech_is_current
a1b2...      A        2024-01-15 00:00:00  2026-03-01 00:00:00  false
a1b2...      B        2026-03-01 00:00:00  NULL                 true
The as-of predicate that follows is identical in every query, has no boundary case, and cannot count a row twice:
sql
The only correct way to join a fact to an SCD2 dimension
SELECT f.order_id, f.amount_eur, d.customer_segment
FROM   gold.fct_order_line f
JOIN   gold.dim_customer   d
  ON   d.customer_sk = f.customer_sk
 AND   f.order_ts >= d._tech_valid_from
 AND  (f.order_ts <  d._tech_valid_to OR d._tech_valid_to IS NULL)
WHERE  f.status <> 'CANCELLED';
An inclusive end forces you to subtract one unit of something, and which unit — a second, a microsecond, a day — is arbitrary, so two engineers answer it differently in two tables. The same argument rules out a 9999-12-31 sentinel: the moment one table uses it and another uses NULL, every as-of helper is wrong on half of them.
Second rule: the transform never calls current_timestamp(). The run's timestamp is a parameter.
python
src/dwh/common/scd2.py — as_of is an argument, never a call
from datetime import datetime

from pyspark.sql import DataFrame


def apply_scd2(current: DataFrame, incoming: DataFrame,
               keys: list[str], as_of: datetime) -> DataFrame:
    """Close changed versions at as_of; open their successors at as_of.

    as_of is injected so the same input yields the same output on every
    run. That property is what the idempotency test asserts.
    """
Three things break when the clock is read inside the transform. The boundary cannot be asserted in a test. A re-run of yesterday's failed load stamps today onto yesterday's change, so the history records when the job ran, not when the business changed. And current_timestamp() is fixed within one query but not across the several a load issues — so a run straddling midnight closes intervals on the 30th and opens their successors on the 31st, a one-day gap appearing only on slow runs, which is to say only in production.
sql
gold.dim_customer — one MERGE that closes and opens, with :as_of bound by the caller
MERGE INTO gold.dim_customer AS tgt
USING (
  -- every staged row once with its key (can close an open version) ...
  SELECT s.customer_sk AS merge_key, s.* FROM stg_customer s
  UNION ALL
  -- ... and the CHANGED ones again with a NULL key, so the second copy
  -- can only ever take the NOT MATCHED branch
  SELECT NULL AS merge_key, s.*
  FROM   stg_customer s
  JOIN   gold.dim_customer d
    ON   d.customer_sk = s.customer_sk AND d._tech_is_current
  WHERE  d._tech_hash <> s._tech_hash
) AS src
ON tgt.customer_sk = src.merge_key AND tgt._tech_is_current

WHEN MATCHED AND tgt._tech_hash <> src._tech_hash THEN UPDATE SET
  tgt._tech_valid_to   = :as_of,
  tgt._tech_is_current = false

-- rule 4: an address change restates the CURRENT row, it does not version
WHEN MATCHED AND (tgt.street      IS DISTINCT FROM src.street
               OR tgt.postal_code IS DISTINCT FROM src.postal_code
               OR tgt.city        IS DISTINCT FROM src.city) THEN UPDATE SET
  tgt.street          = src.street,
  tgt.postal_code     = src.postal_code,
  tgt.city            = src.city,
  tgt._tech_loaded_at = :as_of

WHEN NOT MATCHED THEN INSERT (
  customer_sk, customer_id, customer_name, email, phone, street, postal_code,
  city, country, customer_segment, region_sk,
  _tech_valid_from, _tech_valid_to, _tech_is_current, _tech_hash, _tech_loaded_at
) VALUES (
  src.customer_sk, src.customer_id, src.customer_name, src.email, src.phone,
  src.street, src.postal_code, src.city, src.country, src.customer_segment,
  src.region_sk, :as_of, NULL, true, src._tech_hash, :as_of
);
Which columns go into _tech_hash is the versioning policy. Rule 4 says a segment change makes a version and an address change does not:
sql
_tech_hash — rule 4, expressed as a column
sha2(concat_ws('||',
       coalesce(upper(trim(customer_name)),    '~'),
       coalesce(upper(trim(customer_segment)), '~'),
       coalesce(upper(trim(country)),          '~')
     ), 256) AS _tech_hash
-- street / postal_code / city are deliberately absent.
Why here
Interval boundaries cannot be recovered later. Delta's history records when rows were written; if the boundaries were stamped with the job's clock, the true change dates are gone and no amount of time travel brings them back. And the tracked-attribute list is where the business actually lives — a team that hashes every column creates a version whenever someone corrects a typo in a street name, so the dimension grows tenfold, every as-of join slows, and not one question is answered better.
Doing it
  • Bind :as_of once per run from the job's scheduled time, and pass that value to the pipeline, the MERGE and the test.
  • Keep the tracked-attribute list in one place with the rule number in a comment beside it.
  • Deduplicate stg_customer to one row per customer_id first — Delta fails a MERGE whose source matches a target row twice, and the SAP extract does deliver duplicates.
  • Write the no-overlap and exactly-one-current assertions before the first version row exists.
Embedding it
  • Give them the SCD2 boundary-case table from the invariants route and have them write a failing test per row before any implementation exists.
  • Ask what happens if today's load is re-run tomorrow. If the answer needs thinking about, current_timestamp() is in the code and they will find it faster than you.
  • Let them pick the tracked attributes with the business owner in the room — a business decision in a technical costume.
Defaults
  • Half-open intervals, exclusive end, NULL for open, no sentinel dates anywhere.
  • as_of injected; the transform is a pure function of its inputs.
  • _tech_is_current is a convenience for 'today' queries, never the predicate for a historical one.
  • One apply_scd2 helper for every SCD2 dimension; per-table copies drift within a quarter.
Gotchas
  • current_timestamp() inside the transform. Cheap symptom: a test that can only assert 'not null'. Expensive one: a run straddling midnight leaves a one-day gap between a closed interval and its successor, on slow runs only.
  • Inclusive end dates. Someone writes BETWEEN, the boundary instant belongs to both versions, and that customer's revenue counts twice for one day per change. It reconciles to within a rounding error, so it survives review.
  • Joining on _tech_is_current for a historical question. Last year's revenue is grouped by this year's segment, every trend chart is quietly restated, and it looks exactly like the business changing.
  • A MERGE source with duplicate keys. Delta raises the multiple-source-match error and the job fails — the good outcome. The bad one is a team silencing it with an arbitrary dedupe that keeps the older row half the time.
  • A NULL on either side of tgt._tech_hash <> src._tech_hash. The comparison is NULL, NULL is not true, so the MATCHED branch never fires: that customer keeps one version for the rest of the warehouse's life and no test that counts rows will see it. Declare _tech_hash NOT NULL, or compare with IS DISTINCT FROM as the address clause above does.

#Deletions, late arrivals and the UNKNOWN member

What
A row that disappears. bronze.sap_kna1 is a daily full extract with no change timestamp, so deletion detection is 'present yesterday, absent today' — not the same statement as 'the customer was deleted'. The policy: close the open interval at :as_of and open nothing. History stays intact, the customer has no current version, and no deleted-flag column exists to forget.
sql
Detected deletion — close, never delete
UPDATE gold.dim_customer d
   SET _tech_valid_to   = :as_of,
       _tech_is_current = false
 WHERE d._tech_is_current
   -- the seeded fallback member is in no extract, ever. Without this line it
   -- is the first row every deletion pass closes.
   AND d.customer_sk <> 'UNKNOWN'
   -- NOT EXISTS, not NOT IN: one NULL customer_sk in the staging table makes
   -- NOT IN evaluate to NULL for every row, and the pass closes nothing.
   AND NOT EXISTS (SELECT 1
                   FROM   stg_customer s
                   WHERE  s.customer_sk = d.customer_sk);
The guard in front of it matters more. A truncated extract — the SAP job died halfway, the file is 40 % of its usual size — otherwise closes 60 % of the dimension in one perfectly successful transaction.
sql
Refuse to apply deletions to an implausible extract
SELECT raise_error(
         'sap_kna1 shrank by more than 10 % — refusing to apply deletions')
FROM   (SELECT count(*) AS staged    FROM stg_customer)   s,
       (SELECT count(*) AS live_rows FROM gold.dim_customer
         WHERE _tech_is_current AND customer_sk <> 'UNKNOWN')  t
WHERE  s.staged < t.live_rows * 0.9;
-- the alias is live_rows, not current: CURRENT_DATE / CURRENT_USER and
-- friends make a bare 'current' a word you do not want to be arguing with.
A fact whose dimension member does not exist yet. A webshop order arrives for a customer SAP has not extracted. Because the key is a hash of the business key, the fact can always compute a customer_sk — that is the trap. A computed key with no matching dimension row is an orphan, an inner join drops it, and the revenue appears nowhere. Rule 7 chooses the visible failure instead:
sql
Resolution with an explicit fallback — rule 7
SELECT l.order_id,
       l.line_number,
       coalesce(d.customer_sk, 'UNKNOWN') AS customer_sk,
       o.order_ts
FROM       silver.sales_order_line l
JOIN       silver.sales_order      o ON o.order_id = l.order_id
LEFT JOIN  gold.dim_customer       d
       -- same recipe as make_sk(), or the join silently matches nothing
       ON  d.customer_sk = sha2(
             concat_ws('||', coalesce(upper(trim(o.customer_id)), '~')), 256)
      AND  o.order_ts >= d._tech_valid_from
      AND (o.order_ts <  d._tech_valid_to OR d._tech_valid_to IS NULL);
The unknown member is a real row, seeded by the deployment in every dimension: customer_sk = 'UNKNOWN', _tech_valid_from far in the past, no end. Seeded by the load instead, it appears only after the first unresolvable fact, so the orphan test passes in dev and fails in a clean environment. It also turns an invisible defect into a number you can alert on:
sql
The late-arrival monitor is one query
SELECT date_sk,
       count(*)        AS unresolved_lines,
       sum(amount_eur) AS revenue_at_risk
FROM   gold.fct_order_line
WHERE  customer_sk = 'UNKNOWN'
  AND  date_sk >= current_date() - INTERVAL 7 DAYS
GROUP BY date_sk
ORDER BY date_sk;
Why here
All three are found in production, because none of them occurs in test data anyone writes by hand. The deletion guard in particular is the difference between a bad morning and a bad quarter: closing most of a customer dimension is recoverable with time travel, and invisible until someone asks why revenue halved — by which time two more loads have run on top of it.
Doing it
  • Seed the UNKNOWN member in the same deployment script that creates the dimension.
  • Exclude the UNKNOWN member explicitly from anything that compares the dimension to a source extract — the deletion pass, the shrink guard, the row-count reconciliation. It is in none of them, so every one of those comparisons is wrong by one row in the direction that matters.
  • Put the shrink guard in front of every full-extract deletion pass, threshold in configuration.
  • Alert when the UNKNOWN count crosses zero, not a threshold — the day it starts rising is the cheapest day to fix the source.
  • Add the late-arrival and deletion cases to the fixture factory, or they will never be exercised.
Embedding it
  • Have them break it: truncate a fixture extract to 40 % and run the load against dwh_test. The dimension collapses, they recover it with time travel, and the guard gets written without you asking.
  • Ask what a GDPR erasure request does to an SCD2 dimension and let the silence run. The answer is in the classification route; the discomfort is the teaching.
  • Put the team, not you, on the UNKNOWN alert. Whoever gets paged makes sure the repair job exists.
Defaults
  • Detected deletions close the interval. No rows deleted, no flag column added.
  • A shrink guard in front of every deletion pass driven by a full extract.
  • One seeded UNKNOWN member per dimension, deployed as code, referenced by rule 7.
  • Facts resolve as of order_ts, never as of load time.
Gotchas
  • A truncated full extract applied as deletions. Every job goes green, most of the dimension is closed, and the alarm comes from a human two days and two loads later.
  • A deletion pass that does not exclude the UNKNOWN member. It is in no extract by definition, so the very first run closes it: rule 7's fallback has no current version, the orphan lines resolve to a row that is no longer valid at their order_ts, and the revenue you deliberately made visible goes back to being invisible. Nothing fails; the UNKNOWN monitor simply reads zero and looks like good news.
  • NOT IN against a staging column that can be NULL. One NULL customer_sk and the predicate is NULL for every row, so the deletion pass closes nothing at all — permanently, silently, and in the direction that looks like the system working. NOT EXISTS has no such behaviour.
  • Deleting the row instead of closing the interval. That customer's history is gone, last year's revenue joins to nothing, and the restore is a time-travel exercise under pressure.
  • NULL instead of the UNKNOWN member. An inner join deletes the revenue from the report; a left join produces a NULL group that BI renders as a blank label and readers take for a rounding line.
  • Creating the unknown member from the load rather than the deployment. The orphan test then fails on release day, in the clean environment, for a reason nobody can reproduce locally.
  • Assuming unresolved facts fix themselves. fct_order_line carries no business key, so 'we will re-resolve later' means a permanent UNKNOWN bucket in the revenue report.

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.