13.8 Business Vault & information marts¶
The Raw Vault keeps everything, from everyone — exactly as each source said it. That's perfect for auditing and terrible for a dashboard. The last two layers turn it into answers:
- Business Vault — built from the Raw Vault: PIT tables that make "as of" reads fast, and business rules that decide between sources ("which country is right?").
- Information marts — the star schema people query (Dimensional modeling), built from both.
flowchart LR
classDef hub fill:#4f8ef7,stroke:#1d4fb8,color:#fff
classDef sat fill:#f6c343,stroke:#b8860b,color:#222
classDef bv fill:#8a6ff0,stroke:#5a3fc0,color:#fff
classDef mart fill:#2fb67c,stroke:#16784d,color:#fff
HC(["<b>HUB</b><br/>hub_customer"]):::hub --> PIT["<b>BUSINESS VAULT</b><br/>pit_customer"]:::bv
S1["<b>SAT</b> shop"]:::sat --> PIT
S2["<b>SAT</b> shop loyalty"]:::sat --> PIT
S3["<b>SAT</b> CRM"]:::sat --> PIT
S4["<b>SAT</b> marketplace"]:::sat --> PIT
PIT --> R["<b>BUSINESS RULE</b><br/>pick the best source<br/>per attribute"]:::bv
R --> DC["<b>MART</b><br/>dim_customer"]:::mart
R --> DCH["<b>MART</b><br/>dim_customer_history"]:::mart
Concept¶
The PIT table¶
For every customer and every snapshot date, a PIT row stores which version of each
satellite was valid then — as that version's load_dts, or null if the source didn't know
the customer yet:
| customer | snapshot_dts | shop_ldts | loyalty_ldts | crm_ldts | mkt_ldts |
|---|---|---|---|---|---|
| customer100@… | 2025-01-01 06:00 | 2025-01-01 | 2025-01-01 | 2025-01-01 | null |
| customer100@… | 2025-01-02 06:00 | 2025-01-01 | 2025-01-02 | 2025-01-01 | null |
| customer6001@… | 2025-01-02 06:00 | 2025-01-02 | 2025-01-02 | null | 2025-01-02 |
With it, "customer 100 as of 2 January" is equality joins — sat.customer_hk =
pit.customer_hk and sat.load_dts = pit.loyalty_ldts — instead of a max(load_dts) <= date
search in four satellites. The hard work happens once.
flowchart LR
classDef bv fill:#8a6ff0,stroke:#5a3fc0,color:#fff
classDef sat fill:#f6c343,stroke:#b8860b,color:#222
P["<b>PIT</b> row<br/>customer 100 · 2 Jan<br/>shop → 1 Jan<br/>loyalty → 2 Jan<br/>CRM → 1 Jan"]:::bv
A["<b>SAT</b> shop<br/>version 1 Jan"]:::sat
B["<b>SAT</b> shop loyalty<br/>version 2 Jan"]:::sat
C["<b>SAT</b> CRM<br/>version 1 Jan"]:::sat
P -->|"= shop_ldts"| A
P -->|"= loyalty_ldts"| B
P -->|"= crm_ldts"| C
Business rules¶
The Raw Vault never decides. The business does, once, in writing — and the rule lives in one place everyone can read:
| Attribute | Rule | Why |
|---|---|---|
| name | CRM, else shop, else marketplace | sales keeps real names in the CRM; the shop and marketplace only have display names |
| country | CRM, else shop, else marketplace | the CRM is checked by the sales team |
| order status | marketplace words → shop words: PAID→placed, DISPATCHED→shipped, DELIVERED→delivered, REFUNDED→cancelled |
one status list for every report |
Change a rule tomorrow and you rebuild the marts — the Raw Vault doesn't move.
Lab¶
1 · Build the PIT¶
Snapshot dates = every load date any customer satellite has. For each customer and snapshot,
take the latest load_dts at or before it, per satellite:
%%sql
CREATE OR REPLACE TABLE my.business_vault.pit_customer AS
WITH snapshots AS (
SELECT load_dts AS snapshot_dts FROM my.raw_vault.sat_customer_shop
UNION SELECT load_dts FROM my.raw_vault.sat_customer_shop_loyalty
UNION SELECT load_dts FROM my.raw_vault.sat_customer_crm
UNION SELECT load_dts FROM my.raw_vault.sat_customer_marketplace
),
grid AS (
SELECT h.customer_hk, s.snapshot_dts
FROM my.raw_vault.hub_customer h
CROSS JOIN snapshots s
WHERE h.load_dts <= s.snapshot_dts
),
versions AS (
SELECT customer_hk, load_dts, 'shop' AS sat FROM my.raw_vault.sat_customer_shop
UNION ALL SELECT customer_hk, load_dts, 'loyalty' FROM my.raw_vault.sat_customer_shop_loyalty
UNION ALL SELECT customer_hk, load_dts, 'crm' FROM my.raw_vault.sat_customer_crm
UNION ALL SELECT customer_hk, load_dts, 'mkt' FROM my.raw_vault.sat_customer_marketplace
)
SELECT g.customer_hk, g.snapshot_dts,
max(CASE WHEN v.sat = 'shop' THEN v.load_dts END) AS shop_ldts,
max(CASE WHEN v.sat = 'loyalty' THEN v.load_dts END) AS loyalty_ldts,
max(CASE WHEN v.sat = 'crm' THEN v.load_dts END) AS crm_ldts,
max(CASE WHEN v.sat = 'mkt' THEN v.load_dts END) AS mkt_ldts
FROM grid g
LEFT JOIN versions v
ON v.customer_hk = g.customer_hk AND v.load_dts <= g.snapshot_dts
GROUP BY g.customer_hk, g.snapshot_dts
WITH name AS (…)— CTEs (CTEs & complex analytics).UNION— combines the lists and removes duplicates: every date any satellite loaded.grid— every customer × every snapshot since the customer first appeared.versions— every version of every customer satellite, tagged with its satellite.v.load_dts <= g.snapshot_dts+max(CASE WHEN v.sat = '…' THEN v.load_dts END)— per satellite, the newest version that already existed at the snapshot;nullwhen that source didn't know the customer yet. (A pivot: one column per satellite.)
%%sql
SELECT snapshot_dts, count(*) AS customers,
count(shop_ldts) AS in_shop, count(crm_ldts) AS in_crm, count(mkt_ldts) AS in_marketplace
FROM my.business_vault.pit_customer
GROUP BY snapshot_dts
ORDER BY snapshot_dts
count(column)— counts non-null values: customers each source knew at that snapshot.
Ghost records
Standard Data Vault fills the nulls differently: each satellite gets one ghost record
(hash key of zeros, load_dts = 1900-01-01), and the PIT points at it when a source has no
version — so every mart join is an inner join. Here the PIT keeps null and the marts use
left joins: simpler to learn, same answers.
2 · The customer dimension, as of every snapshot¶
Join the PIT to each satellite on equality, then apply the business rules with
coalesce — the first non-null value wins:
%%sql
CREATE OR REPLACE TABLE my.info_mart.dim_customer_history AS
SELECT p.snapshot_dts,
h.email,
coalesce(concat(c.first_name, ' ', c.last_name), s.full_name, m.buyer_name) AS name,
coalesce(c.country, s.country, m.buyer_country) AS country,
s.shop_customer_id,
c.crm_id,
c.segment,
l.loyalty_tier,
l.marketing_opt_in,
s.customer_hk IS NOT NULL AS in_shop,
c.customer_hk IS NOT NULL AS in_crm,
m.customer_hk IS NOT NULL AS in_marketplace
FROM my.business_vault.pit_customer p
JOIN my.raw_vault.hub_customer h
ON h.customer_hk = p.customer_hk
LEFT JOIN my.raw_vault.sat_customer_shop s
ON s.customer_hk = p.customer_hk AND s.load_dts = p.shop_ldts
LEFT JOIN my.raw_vault.sat_customer_shop_loyalty l
ON l.customer_hk = p.customer_hk AND l.load_dts = p.loyalty_ldts
LEFT JOIN my.raw_vault.sat_customer_crm c
ON c.customer_hk = p.customer_hk AND c.load_dts = p.crm_ldts
LEFT JOIN my.raw_vault.sat_customer_marketplace m
ON m.customer_hk = p.customer_hk AND m.load_dts = p.mkt_ldts
coalesce(crm, shop, marketplace)— the business rule, in one line: the CRM if it has a value, else the shop, else the marketplace.concat(…)returnsnullif any part is null, so a customer missing from the CRM falls through to the shop's name.
Ask it about customer 72 (moved country in the shop on Day 2):
%%sql
SELECT snapshot_dts, email, name, country, in_shop, in_crm, in_marketplace
FROM my.info_mart.dim_customer_history
WHERE email = 'customer72@example.com'
ORDER BY snapshot_dts
Example output:
| snapshot_dts | name | country | in_shop | in_crm | in_marketplace | |
|---|---|---|---|---|---|---|
| 2025-01-01 06:00:00 | customer72@example.com | Ravi Jones | IN | true | true | false |
| 2025-01-02 06:00:00 | customer72@example.com | Ravi Jones | IN | true | true | false |
Customer 72 moved to NL in the shop — but the dimension still says the CRM's country,
because the rule trusts the CRM first, and the CRM wasn't updated. That's not a bug: it's
the rule working, visibly. The shop's NL is still in the vault; if the business decides the
shop wins for country, change one coalesce and rebuild. (If customer 72 isn't in your CRM,
the shop's NL shows on Day 2 — the rule falls through.)
3 · The current customer dimension¶
%%sql
CREATE OR REPLACE TABLE my.info_mart.dim_customer AS
SELECT email, name, country, shop_customer_id, crm_id, segment, loyalty_tier,
marketing_opt_in, in_shop, in_crm, in_marketplace
FROM my.info_mart.dim_customer_history
WHERE snapshot_dts = (SELECT max(snapshot_dts) FROM my.business_vault.pit_customer)
One row per person ShopFlow knows — from any system:
%%sql
SELECT in_shop, in_crm, in_marketplace, count(*) AS customers
FROM my.info_mart.dim_customer
GROUP BY 1, 2, 3
ORDER BY customers DESC
The same table you produced by hand in Meet the sources — now as a governed, historised dimension.
4 · A product dimension — latest version, no PIT¶
With one question ("what's the product now"), pick each satellite's latest version directly:
%%sql
CREATE OR REPLACE TABLE my.info_mart.dim_product AS
SELECT h.sku,
coalesce(d.name, 'Marketplace-only product') AS name,
coalesce(d.category, 'Unknown') AS category,
p.price, p.cost
FROM my.raw_vault.hub_product h
LEFT JOIN (SELECT *, row_number() OVER (PARTITION BY product_hk ORDER BY load_dts DESC) AS rn
FROM my.raw_vault.sat_product_shop) d
ON d.product_hk = h.product_hk AND d.rn = 1
LEFT JOIN (SELECT *, row_number() OVER (PARTITION BY product_hk ORDER BY load_dts DESC) AS rn
FROM my.raw_vault.sat_product_shop_price) p
ON p.product_hk = h.product_hk AND p.rn = 1
203 rows. The 3 marketplace-only SKUs get a label instead of disappearing — a mart decision,
made visibly.
5 · The fact table: order lines from both channels¶
The grain is the link lnk_order_product — one row per product per order, shop and
marketplace. Each order's latest status comes from its source's satellite, harmonised by the
status rule:
%%sql
CREATE OR REPLACE TABLE my.info_mart.fact_order_line AS
SELECT ho.bkcc AS channel_source,
ho.order_no,
hc.email AS customer_email,
hp.sku,
coalesce(os.order_ts, om.ordered_at) AS order_ts,
cast(coalesce(os.order_ts, om.ordered_at) AS date) AS order_date,
coalesce(os.status,
CASE om.order_status
WHEN 'PAID' THEN 'placed'
WHEN 'DISPATCHED' THEN 'shipped'
WHEN 'DELIVERED' THEN 'delivered'
WHEN 'REFUNDED' THEN 'cancelled' END) AS status,
sol.quantity,
sol.unit_price,
sol.quantity * sol.unit_price AS line_revenue
FROM my.raw_vault.lnk_order_product lop
JOIN my.raw_vault.hub_order ho ON ho.order_hk = lop.order_hk
JOIN my.raw_vault.hub_product hp ON hp.product_hk = lop.product_hk
JOIN my.raw_vault.lnk_order_customer loc ON loc.order_hk = lop.order_hk
JOIN my.raw_vault.hub_customer hc ON hc.customer_hk = loc.customer_hk
JOIN (SELECT *, row_number() OVER (PARTITION BY order_product_hk ORDER BY load_dts DESC) AS rn
FROM my.raw_vault.sat_order_line) sol
ON sol.order_product_hk = lop.order_product_hk AND sol.rn = 1
LEFT JOIN (SELECT *, row_number() OVER (PARTITION BY order_hk ORDER BY load_dts DESC) AS rn
FROM my.raw_vault.sat_order_shop) os
ON os.order_hk = lop.order_hk AND os.rn = 1
LEFT JOIN (SELECT *, row_number() OVER (PARTITION BY order_hk ORDER BY load_dts DESC) AS rn
FROM my.raw_vault.sat_order_marketplace) om
ON om.order_hk = lop.order_hk AND om.rn = 1
CASE om.order_status WHEN … THEN …— the status rule: marketplace words → shop words.coalesce(os.…, om.…)— a shop order has a shop satellite, a marketplace order a marketplace one; take whichever exists.
116002 rows: 100,000 shop lines + 16,002 marketplace lines.
6 · Prove it: nothing lost, nothing doubled¶
%%sql
SELECT channel_source, count(*) AS lines, sum(line_revenue) AS from_vault
FROM my.info_mart.fact_order_line
GROUP BY channel_source
ORDER BY channel_source
%%sql
SELECT 'MKT' AS channel_source, sum(qty * item_price) AS from_source FROM my.marketplace.order_lines
UNION ALL
SELECT 'SHOP', sum(quantity * unit_price) FROM shared.bronze.order_items
SHOP matches Bronze exactly. MKT is the source total plus 41.48 — MP-200001's two
Day 2 lines (1 × 21.50 + 2 × 9.99). Two order systems, one fact table, every cent accounted for.
7 · Questions only the vault can answer¶
Revenue by channel, for customers who buy in both:
%%sql
WITH both_channels AS (
SELECT customer_email
FROM my.info_mart.fact_order_line
GROUP BY customer_email
HAVING count(DISTINCT channel_source) = 2
)
SELECT f.channel_source,
count(DISTINCT f.customer_email) AS customers,
count(DISTINCT f.order_no) AS orders,
sum(f.line_revenue) AS revenue
FROM my.info_mart.fact_order_line f
JOIN both_channels b ON b.customer_email = f.customer_email
GROUP BY f.channel_source
And by CRM segment — a CRM attribute on marketplace revenue, joined through the e-mail that no single system shared before:
%%sql
SELECT coalesce(c.segment, 'not in CRM') AS segment, f.channel_source,
sum(f.line_revenue) AS revenue
FROM my.info_mart.fact_order_line f
JOIN my.info_mart.dim_customer c ON c.email = f.customer_email
GROUP BY 1, 2
ORDER BY revenue DESC
Nobody querying these tables needs to know a vault exists. That's the point: the vault is for integrating and keeping; marts are for reading — and every mart can be dropped and rebuilt from the vault at any time, in any shape, with any rule.
Marts as views
Many teams build information marts as views on the vault and PIT instead of tables — always current, no extra storage. Tables are faster for big dashboards. Both are fine.
Check yourself¶
- Customer 72 is
NLin the shop but the CRM's country indim_customer. Bug? (No — the business rule prefers the CRM. Both values are in the vault; the rule is onecoalesceaway from changing.) - Why are business rules in the Business Vault / marts and not in the satellite loads? (The Raw Vault keeps what each source said. Rules change; history mustn't.)
- Where would you add a fourth source — say, a support desk with customer e-mails?
(Staging for it, its own satellite on
hub_customer, one more column in the PIT, one more argument in eachcoalesce. Nothing existing changes.)