Section B · Architecture foundations

Data Modeling

Ingestion lands raw bytes; storage holds them cheaply. Modeling is where bytes become answers. This chapter turns GridDP's nine sources into a layered, queryable model: the medallion refinement path, the paradigm choice, slowly-changing dimensions that preserve history, and a full star schema built on the three canonical grains from Start Here — with the DDL to back it.

The medallion architecture

Raw source data is not safe to query and not safe to delete. The medallion architecture resolves that tension by refining data through three named layers — bronze, silver, gold — each with a different contract about cleanliness, structure, and audience. It is the organizing skeleton for every transformation that follows in chapter 07.

SOURCES (ch.01) BRONZE SILVER GOLD ────────────── ─────────── ─────────── ────────── marketplace-api ─CDC─┐ ┌─ marketplace mart billing-service ─CDC─┤ raw, immutable cleaned, deduped, │ (liquidity, funnel) orchestrator ─CDC─┼──▶ landing as-received ──▶ conformed, typed, ──▶ ├─ billing mart ranking-service ─CDC─┤ schema-on-read CDC-merged into │ (GMV, take-rate) trust-safety ─strm─┤ 1 table / source current + history ├─ supply/fleet mart mkt-events ─strm──┤ append-only (dims + facts) │ (utilization, fill) host-agent ⚡ ─strm──┤ └─ ML feature tables payments ─batch─┤ "what arrived" "what is true" "what the business support ─batch─┘ measures" ▲ ▲ ▲ no business logic SCD2, dedupe, joins, star schema, replayable, audit type casting, contracts metrics, OBT kept ~30–90d hot the integration layer the serving layer

Each hop has a job, and only that job:

  • Bronze — raw, immutable landing. Data lands exactly as it arrived: CDC change events from marketplace-api, Kafka payloads from marketplace-events, JSON pulls from payments. One table per source stream, append-only, schema-on-read, no business logic. Its only promises are completeness and replayability — if silver logic is wrong, you reprocess from bronze rather than re-pulling from the source. This is the audit floor and the reason a DPE can sleep.
  • Silver — cleaned, conformed, integrated. Here the transforms happen: deduplicate on the contract's event_id, cast strings to typed columns, apply the CDC merge that collapses an insert/update/delete change stream into a current-state table and a history table (the SCD2 work of the next section), conform keys across sources, enforce data contracts. Silver is the integration layer — entity-centric, not source-centric. An analyst could query it; mostly machines do.
  • Gold — business-facing marts. Dimensional models and metric tables shaped for a question, not a source: a marketplace mart, a billing mart, a supply mart, wide ML feature tables. Aggregated, denormalized, documented, governed. This is what the semantic layer (ch. 07 and ch. 08) and dashboards point at.
Why three layers and not one

The layers separate three independent concerns that drift at different rates: fidelity (bronze never changes — it's what happened), truth (silver changes when your cleaning/conforming logic improves), and shape (gold changes when the business asks a new question). Collapse them and a pricing-logic fix forces a full re-pull from production Postgres, or a renamed metric silently rewrites history. Keep them and each can evolve without dragging the others.

Modeling paradigms compared

Bronze and silver are mechanical; the interesting choice is how to shape gold. Three paradigms dominate, and a mature platform uses more than one.

  • Kimball dimensional (star schema). Split the world into facts (measurable events at a declared grain — a rental-hour, a bid) and dimensions (the descriptive context you filter and group by — customer, machine, date). Facts hold foreign keys to dimensions; queries fan out from a central fact table into surrounding dims, forming a star. Optimized for analyst query ergonomics.
  • Data Vault 2.0. Decompose into hubs (business keys, e.g. a machine's natural key), links (relationships between hubs, e.g. machine↔rental), and satellites (the descriptive, time-stamped attributes that hang off hubs and links). Built for auditability and schema agility under many fast-changing sources; not pleasant to query directly — you still build dimensional marts on top.
  • One Big Table (OBT) / wide denormalized. Pre-join everything into a single very wide table, one row per event with every attribute inlined. No joins at query time. Cheap and fast on columnar engines, brilliant for ML feature tables and embedded analytics; painful for history, storage, and any change to a dimension's definition.
DimensionKimball dimensionalData Vault 2.0One Big Table
Modeling complexityModerateHigh (many small tables)Low
Query ergonomicsExcellent (intuitive star joins)Poor (must assemble)Excellent (no joins)
History handlingGood (via SCD2)Excellent (native, time-stamped sats)Poor (duplication explodes)
Schema agilityModerate (refactor on change)Excellent (add sats/links freely)Poor (rewrite the table)
Storage / computeEfficientHeavy (joins + sat sprawl)Heavy (wide, redundant)
When to useBusiness-facing marts, BI, metricsHighly regulated, many volatile sourcesML features, single-purpose wide tables
GridDP's choice

Gold marts are Kimball dimensional. GridDP has a small, stable set of sources (nine) and a consumer base that lives in SQL and BI — exactly where star schemas shine, and exactly where the semantic layer (ch. 08) expects facts-and-dimensions to map metrics onto. We reserve OBT for ML feature tables and the embedded supply-ops views where a single wide row per machine-hour beats a six-way join. We skip Data Vault: its agility tax only pays off with dozens of churning sources and hard audit mandates we don't have. The double-entry ledger's audit need is met by bronze immutability plus reconciliation tests (ch. 10), not by vaulting the whole warehouse.

Slowly-changing dimensions

Dimensions describe entities — but entities change. A customer upgrades tiers; a host relocates; an offer's price and a machine's DLPerf score move with the market. How a dimension table handles those changes is its slowly-changing dimension (SCD) type.

TypeBehavior on changeHistory preserved?GridDP use
Type 1Overwrite the column in placeNone — only "now" is knowableCosmetic attrs (display name, email)
Type 2Close the old row, insert a new versioned rowFull — every version, time-boundeddim_offer, dim_host, dim_customer
Type 3Keep a previous_value column alongside currentOne prior value onlyRare — e.g. prior_tier for a single report

The reason SCD2 is load-bearing at GridDP, not a nicety: billing and analytics constantly ask "what was true at the time of the event?" What was the offer.price at the hour this rental was metered? What was the machine's DLPerf score when the customer chose it? With a Type 1 dimension you only know today's price, so you mis-bill historical rentals and mis-train the ranking model. SCD2 makes the dimension temporal: every fact joins to the dimension version that was current at the fact's event time.

dim_offer — SCD2 history for offer_id = "off_55a2" (price changes over time) surrogate_key offer_id price_usd_hr valid_from valid_to is_current ────────────── ────────── ───────────── ───────────────────── ───────────────────── ────────── 9001 off_55a2 1.20 2026-06-01 00:00:00Z 2026-06-08 09:00:00Z false 9044 off_55a2 0.95 2026-06-08 09:00:00Z 2026-06-14 17:30:00Z false ◀── price drop 9098 off_55a2 1.40 2026-06-14 17:30:00Z 9999-12-31 00:00:00Z true ◀── current a rental metered at 2026-06-10 12:00Z → joins to surrogate_key 9044 → bills at $0.95/hr ✓ not today's $1.40. The fact carries the surrogate key it matched at event time; the price is frozen correct.

The mechanics tie straight back to CDC from chapter 04. Debezium streams each offer.price UPDATE off the Postgres WAL into bronze; the silver merge turns that change stream into the versioned rows above. A new natural-key change closes the prior row (sets valid_to and is_current = false) and inserts a fresh version with a new surrogate key. The merge:

silver: SCD2 merge for dim_offer
-- 1. Close the currently-open version when an incoming change differs.
MERGE INTO dim_offer AS tgt
USING (
  -- latest CDC state per offer from bronze, this batch
  SELECT offer_id, machine_id, price_usd_hr, is_interruptible,
         min_bid_usd_hr, cdc_commit_ts
  FROM   bronze_marketplace_cdc
  WHERE  table_name = 'offer'
    AND  op IN ('c','u')               -- create / update
    AND  cdc_commit_ts > (SELECT COALESCE(MAX(valid_from), '1970-01-01') FROM dim_offer)
) AS src
ON  tgt.offer_id = src.offer_id
AND tgt.is_current = TRUE
WHEN MATCHED AND (
       tgt.price_usd_hr     <> src.price_usd_hr
    OR tgt.is_interruptible <> src.is_interruptible
    OR tgt.min_bid_usd_hr   <> src.min_bid_usd_hr )   -- a tracked attribute changed
  THEN UPDATE SET valid_to = src.cdc_commit_ts, is_current = FALSE;

-- 2. Insert the new version row (new surrogate key) for changed / new offers.
INSERT INTO dim_offer
  (offer_sk, offer_id, machine_id, price_usd_hr, is_interruptible,
   min_bid_usd_hr, valid_from, valid_to, is_current)
SELECT
  nextval('offer_sk_seq'), src.offer_id, src.machine_id, src.price_usd_hr,
  src.is_interruptible, src.min_bid_usd_hr,
  src.cdc_commit_ts, TIMESTAMP '9999-12-31 00:00:00', TRUE
FROM bronze_marketplace_cdc AS src
WHERE /* offer is new, or its current row was just closed above */ TRUE;
Surrogate keys, not natural keys

Facts must reference the SCD2 dimension by its surrogate key (offer_sk = 9044), never the natural key (offer_id = "off_55a2"). The natural key is shared by every version; only the surrogate identifies which version was current at event time. Join a fact on the natural key and you silently re-bill all history at today's price — the exact bug SCD2 exists to prevent.

Fact-table grain

The first decision in any fact table — before columns, before keys — is declaring the grain: what does exactly one row mean? Skip this and you get tables where some rows are per-event and some pre-aggregated, additivity breaks, and no one trusts a SUM(). Kimball's rule: declare the grain, then add only dimensions and measures true at that grain.

GridDP's three grains are the ones memorized in Start Here:

Fact tableGrain — one row per…SourceVolume/dayKey measures
fct_telemetry_hourlymachine + GPU + hour (rollup of raw samples)host-agent ⚡~17k × 24 GPU-hrsavg_util_pct, max_temp_c, energy_wh, dlperf
fct_rental_meterrental-hour — the billing atombilling-service~10 Mbilled_hours, amount_usd, gpu_seconds
fct_bid_eventbid event (placed / outbid / cleared)marketplace-events~part of 20–40 Mbid_usd_hr, cleared_flag

Each grain demands the right treatment of its measures — the additivity classification:

  • Additive — sums correctly across every dimension, including time. amount_usd, billed_hours, energy_wh, gpu_seconds. These are the safe, default measures: SUM them across any slice.
  • Semi-additive — sums across some dimensions but not time. A point-in-time balance like ledger_entry-derived account balance, or "active rentals at the hour": you sum across customers but you average (or take the last value) across time, never sum hourly snapshots into a day.
  • Non-additive — cannot be summed across any dimension; must be recomputed from its components. Utilization % is the canonical GridDP trap: you cannot average hourly avg_util_pct to get daily utilization (hours have different GPU-second denominators). Store the additive numerator and denominator (busy_gpu_seconds, total_gpu_seconds) and divide at query time. Same for fill rate and take-rate %.
Store ratios as their parts

The single most common modeling defect in a marketplace warehouse is a stored percentage. Utilization, fill rate, take-rate, and CSAT are all non-additive ratios. Persist the additive components in the fact and let the semantic layer (ch. 08) compute the ratio at the requested grain. A column literally named utilization_pct in a fact table is a bug waiting to be averaged.

The GridDP dimensional model

Putting it together: the gold layer is a set of star schemas sharing conformed dimensions, with the three fact tables at their centers. Here is the full ERD.

dim_date (calendar, hr) │ dim_host (SCD2) │ dim_customer (SCD2) ┌───────────────┐ │ ┌────────────────┐ │ host_sk (PK) │ │ │ customer_sk(PK)│ │ host_id (NK) │ │ │ customer_id(NK)│ │ region │ │ │ tier, kyc_level│ │ payout_rail │ │ │ valid_from/to │ │ valid_from/to │ │ └───────┬────────┘ └──────┬────────┘ │ │ │ ┌─────────┴──────────┐ │ │ ▼ ▼ │ ┌──────┴────────┐ ┌─────────────────┐ ┌──────────────────┴─┐ │ dim_machine │ │ fct_rental_meter│ │ fct_bid_event │ │ machine_sk PK │◀─┤ rental_meter_sk │ │ bid_event_sk PK │ │ machine_id NK │ │ ─ FKs ─ │ │ ─ FKs ─ │ │ host_sk FK │ │ machine_sk FK │ │ customer_sk FK │ │ gpu_model │ │ customer_sk FK │ │ offer_sk FK │ └──────┬────────┘ │ offer_sk FK │ │ machine_sk FK │ │ │ date_sk FK │ │ date_sk FK │ │ 1 │ ─ measures ─ │ │ ─ measures ─ │ │ * │ billed_hours │ │ bid_usd_hr │ ┌──────┴────────┐ │ amount_usd │ │ cleared_flag │ │ dim_gpu │ │ gpu_seconds │ │ event_type │ │ gpu_sk PK │ └─────────────────┘ └─────────────────────┘ │ machine_sk FK │ ▲ │ gpu_index │ │ joins on machine_sk + hour │ vram_gb │ ┌────────┴────────────┐ dim_offer (SCD2) └───────────────┘ │ fct_telemetry_hourly│ ┌────────────────┐ │ machine_sk FK │ │ offer_sk PK │ │ gpu_index │ │ offer_id NK │ │ date_sk (hour) FK │ │ machine_sk FK │ │ avg_util_pct (ratio│ │ price_usd_hr │ │ parts: busy/total)│ │ is_interruptible│ │ energy_wh, dlperf │ │ valid_from/to │ └─────────────────────┘ └────────────────┘ ◀── the bridge from ClickHouse (raw) to the warehouse ──▶

The DDL for the load-bearing tables (Stack-A Iceberg / ANSI-SQL flavor; partition hints noted):

gold: SCD2 dimensions
CREATE TABLE dim_customer (
  customer_sk    BIGINT       NOT NULL,           -- surrogate (PK)
  customer_id    VARCHAR      NOT NULL,           -- natural key from marketplace-api.account
  tier           VARCHAR,                          -- free / pro / enterprise
  kyc_level      VARCHAR,                          -- from payments KYC/AML source
  country        VARCHAR,
  is_provider    BOOLEAN,                          -- two-sided: a customer can also be a host owner
  valid_from     TIMESTAMP    NOT NULL,
  valid_to       TIMESTAMP    NOT NULL DEFAULT TIMESTAMP '9999-12-31 00:00:00',
  is_current     BOOLEAN      NOT NULL,
  PRIMARY KEY (customer_sk)
);                                                 -- small dim; no partitioning needed

CREATE TABLE dim_host (
  host_sk        BIGINT       NOT NULL,
  host_id        VARCHAR      NOT NULL,           -- natural key (provider)
  region         VARCHAR,
  payout_rail    VARCHAR,                          -- fiat / crypto (ch.01 payments)
  reliability_tier VARCHAR,
  valid_from     TIMESTAMP    NOT NULL,
  valid_to       TIMESTAMP    NOT NULL DEFAULT TIMESTAMP '9999-12-31 00:00:00',
  is_current     BOOLEAN      NOT NULL,
  PRIMARY KEY (host_sk)
);

CREATE TABLE dim_offer (
  offer_sk         BIGINT     NOT NULL,
  offer_id         VARCHAR    NOT NULL,           -- natural key
  machine_sk       BIGINT     NOT NULL,           -- FK -> dim_machine
  price_usd_hr     DECIMAL(10,4),
  is_interruptible BOOLEAN,
  min_bid_usd_hr   DECIMAL(10,4),
  valid_from       TIMESTAMP  NOT NULL,
  valid_to         TIMESTAMP  NOT NULL DEFAULT TIMESTAMP '9999-12-31 00:00:00',
  is_current       BOOLEAN    NOT NULL,
  PRIMARY KEY (offer_sk)
);
gold: Type-1 dimensions
CREATE TABLE dim_machine (
  machine_sk   BIGINT   NOT NULL,                 -- surrogate (PK)
  machine_id   VARCHAR  NOT NULL,                 -- natural key (the physical box)
  host_sk      BIGINT   NOT NULL,                 -- FK -> dim_host
  gpu_model    VARCHAR,                            -- e.g. RTX 4090, H100
  gpu_count    INT,
  cpu_cores    INT,
  ram_gb       INT,
  datacenter   BOOLEAN,                            -- true=DC, false=hobbyist/residential
  PRIMARY KEY (machine_sk)
);

CREATE TABLE dim_gpu (
  gpu_sk       BIGINT   NOT NULL,                 -- one row per physical card
  machine_sk   BIGINT   NOT NULL,                 -- FK -> dim_machine
  gpu_index    INT      NOT NULL,                 -- which slot in the box (0..n)
  vram_gb      INT,
  pcie_gen     INT,
  PRIMARY KEY (gpu_sk)
);
gold: fact tables (declared grains)
-- GRAIN: one row per rental-hour. The billing atom. Reconciles to ledger_entry.
CREATE TABLE fct_rental_meter (
  rental_meter_sk BIGINT     NOT NULL,
  rental_id       VARCHAR    NOT NULL,            -- degenerate dimension (the rental)
  customer_sk     BIGINT     NOT NULL,            -- FK -> dim_customer (version at event time)
  machine_sk      BIGINT     NOT NULL,            -- FK -> dim_machine
  offer_sk        BIGINT     NOT NULL,            -- FK -> dim_offer  (SCD2: price AT this hour)
  date_sk         INT        NOT NULL,            -- FK -> dim_date (hour grain)
  meter_hour      TIMESTAMP  NOT NULL,
  billed_hours    DECIMAL(8,4),                   -- additive
  gpu_seconds     BIGINT,                         -- additive
  amount_usd      DECIMAL(12,4),                  -- additive; ties to invoice/ledger
  PRIMARY KEY (rental_meter_sk)
) PARTITIONED BY (days(meter_hour));              -- partition by day

-- GRAIN: one row per bid event.
CREATE TABLE fct_bid_event (
  bid_event_sk  BIGINT     NOT NULL,
  bid_id        VARCHAR    NOT NULL,              -- degenerate dimension
  customer_sk   BIGINT     NOT NULL,              -- FK -> dim_customer
  offer_sk      BIGINT     NOT NULL,              -- FK -> dim_offer
  machine_sk    BIGINT     NOT NULL,              -- FK -> dim_machine
  date_sk       INT        NOT NULL,              -- FK -> dim_date
  event_time    TIMESTAMP  NOT NULL,              -- producer-stamped (ch.04)
  event_type    VARCHAR,                          -- placed / outbid / cleared / cancelled
  bid_usd_hr    DECIMAL(10,4),                    -- additive only within same event_type
  cleared_flag  BOOLEAN,
  PRIMARY KEY (bid_event_sk)
) PARTITIONED BY (days(event_time));

-- GRAIN: one row per machine + GPU + hour. The rolled-up firehose (raw stays in ClickHouse).
CREATE TABLE fct_telemetry_hourly (
  machine_sk        BIGINT    NOT NULL,           -- FK -> dim_machine
  gpu_index         INT       NOT NULL,
  date_sk           INT       NOT NULL,           -- FK -> dim_date (hour grain)
  metric_hour       TIMESTAMP NOT NULL,
  busy_gpu_seconds  BIGINT,                        -- additive NUMERATOR for utilization
  total_gpu_seconds BIGINT,                        -- additive DENOMINATOR (do NOT store the %)
  max_temp_c        SMALLINT,                      -- non-additive across time (it's a max)
  energy_wh         DECIMAL(12,3),                 -- additive
  dlperf_avg        DECIMAL(8,2),                  -- semi-additive (average, not sum)
  sample_count      INT,                           -- rows rolled up; gap detection
  PRIMARY KEY (machine_sk, gpu_index, date_sk)
) PARTITIONED BY (days(metric_hour));
Reading the model

Three things to notice. (1) Every fact carries surrogate FKs to dimensions, and the SCD2 dims (dim_offer, dim_host, dim_customer) resolve to the version current at the fact's event time. (2) rental_id and bid_id live on the fact as degenerate dimensions — IDs with no descriptive attributes of their own. (3) fct_telemetry_hourly stores utilization as busy_gpu_seconds / total_gpu_seconds, never a percentage — the non-additive rule from the grain section.

Conformed dimensions

Notice that dim_customer, dim_machine, and dim_date appear in multiple stars — the marketplace mart, the billing mart, and the supply mart all reference the same physical dimension tables. That is the definition of a conformed dimension: a single, shared dimension used identically across fact tables and marts.

Conformance is what lets metrics from different domains line up. "GMV per customer tier" (billing mart) and "bids per customer tier" (marketplace mart) only join meaningfully if both use the same dim_customer with the same tier definition and the same surrogate keys. The moment two teams build their own customer dimension with subtly different tier logic, the company gets two different "enterprise customer" counts in two dashboards — and weeks are lost reconciling numbers that should have been one number.

Conformance prevents metric divergence

The failure mode of an un-conformed warehouse is not a crash — it's a meeting where Finance and Product disagree on a number and no one can say who's right. A conformed-dimension bus (Kimball's term) is the structural defense: define each shared entity once, version it once with SCD2, and point every mart at it. This is also the seam the semantic layer (ch. 08) binds to — it can only guarantee one definition per metric if the underlying dimensions are themselves singular. Conformance in the model and the semantic layer above it are two halves of the same promise.

dim_date is the simplest and most important conformed dimension: one row per hour, with calendar attributes (day, week, month, quarter, fiscal period, is-weekend). Every fact in GridDP joins to it, so "by month" means the same month everywhere — including the fiscal calendar Finance reconciles against.

Modeling the firehose alongside the marts

The dimensional model above is a row-and-column world built for joins. The telemetry firehose is a different world: ~150 M samples/day, time-series access, served from ClickHouse per chapter 05. The modeling question is how the two worlds meet — because supply ops and Finance both need to join physical GPU behavior to business facts.

The answer is the rollup as a bridge. Raw per-sample telemetry never enters the warehouse; it stays in ClickHouse where time-series queries are cheap. A scheduled job aggregates raw samples into fct_telemetry_hourly — one row per machine_id + gpu_index + hour — and that table flows into the lakehouse/warehouse, keyed so it joins cleanly to dim_machine (via machine_sk) and aligns hour-for-hour with fct_rental_meter.

ClickHouse (raw, ch.05) rollup job Lakehouse / warehouse (gold) ─────────────────────── ────────── ───────────────────────────── telemetry_sample fct_telemetry_hourly ~150 M rows/day ──hourly──▶ GROUP BY ──▶ key: machine_sk + gpu_index + hour machine_id, gpu_index, machine_id, busy/total_gpu_seconds (util parts) event_time (10s), gpu_index, energy_wh, max_temp_c, dlperf_avg util, temp, power, dlperf date_trunc(hour) ▲ │ joins on machine_sk + hour stays here for sub-second ~17k × 24 ▼ time-series dashboards = ~400k rows/day fct_rental_meter (was the GPU (ops, "is GPU X hot now?") (a 375× reduction) actually delivering this hour?)

This single design decision earns its keep three ways. It keeps the firehose out of an engine that would choke on it (the chapter-01 anti-pattern). It makes telemetry joinable to business data — you can now answer "was this billed rental-hour actually delivering compute?" by joining fct_rental_meter to fct_telemetry_hourly on machine_sk + hour, the exact reconciliation Finance and supply ops need. And it shrinks ~150 M raw rows to ~400 k hourly rows — a ~375× reduction — so the warehouse-side telemetry fact is small enough to be a first-class star participant.

The rollup is a modeling artifact, not just an optimization

It is tempting to think of the hourly rollup as purely a cost trick. It is also a modeling decision: by declaring the rollup grain (machine + GPU + hour) you make telemetry conform to the same time and machine dimensions as the rest of the warehouse. The grain you choose for the bridge is the resolution at which physical behavior can ever be joined to money. Choose hourly because that is the billing-meter grain; the two facts then align perfectly.

Takeaway

  • The medallion architecture refines data through bronze (raw, immutable, replayable), silver (cleaned, deduped, conformed, CDC-merged), and gold (business-facing marts) — separating fidelity, truth, and shape.
  • GridDP models gold as Kimball dimensional stars for BI and metrics, with OBT reserved for ML feature tables; Data Vault's agility tax isn't warranted here.
  • SCD2 dimensions make history queryable — joining a fact to the dimension version current at event time is what lets GridDP bill a rental at the offer price that was live that hour, not today's.
  • Every fact declares its grain first; the three canonical grains are telemetry-hourly, rental-hour (the billing atom), and bid event. Non-additive ratios like utilization are stored as additive numerator/denominator parts.
  • Conformed dimensions (dim_customer, dim_machine, dim_date) shared across marts are the structural defense against metric divergence.
  • The firehose joins the marts through an hourly rollup — raw stays in ClickHouse, fct_telemetry_hourly bridges into the warehouse at the billing grain.

The model is the contract for what the data should look like. Next we build the machinery that produces and maintains it on a schedule, with tests and lineage. → Transformation & Orchestration