The brief, and how you will know you are done
Bazaar accepts returns. A shopper requests a return on an order line, gives a reason - damaged, wrong item, changed mind, not as described - and the merchant approves or rejects it. An approved return triggers a refund, and the returned unit comes back either resellable or written off. Refunds already exist in transactions as negative amounts with status 'refunded', which is exactly the sort of half-modelled subject area you inherit in real life: the money is recorded, the event is not.
Nine sessions of patterns are your scaffolding. sql/01_oltp_ddl.sql is your DDL style guide, sql/10 and sql/11 are your dimension and fact conventions, sql/40_agent_views.sql is your naming and view contract, and python/validate_model.py is your grader. Nothing here needs a technique you have not already used. What it needs is the judgement to pick the right one, which is the whole point of a capstone.
returns table in the OLTP schema at return-line grain, with a constrained reason set, a constrained status set, a composite reference to (order_id, line_no), and one index on the access path you will actually query. One new dimension, dim_return_reason, with an unknown member. One new fact, fact_return, at return-line grain, reusing the existing conformed dimensions rather than inventing new ones. One mart at merchant x day grain carrying returns and return value. One agent view, v_return_line, following the same naming rules as the other five. Three new rows in v_metric_definitions - return_rate, return_value, resellable_share - each with a definition, an expression, a grain and an honest caveat. Two new golden questions with the SQL you expect. Then python python/validate_model.py and all 27 checks green.
What you are building 3 min live
Four new objects and three new metric rows, bolted onto eight tables, six dimensions, three facts, four marts and five views that all stay exactly as they are. If your design forces you to change something on the left-hand side of this diagram, stop and re-read the grain sentence - it usually means you attached returns to the wrong thing.
Here is the clock. Five checkpoints, each with a deliverable you can hold up and a trap that has cost somebody a quarter of bad reporting. The minutes are tight on purpose: a capstone that lets you gold-plate checkpoint 1 teaches you nothing about sequencing.
LiveWork in this order, and do not skip ahead2 min▶
The ladder is the deliverable as much as the tables are. Every time a team has skipped a rung on a subject area this size, the same thing happens: the DDL gets written first, the conceptual question ("is a return an entity?") gets answered implicitly by whoever typed fastest, and the answer turns out to be wrong three months later when somebody asks for return reasons by merchant.
- Conceptual before columns. Eight minutes with no SQL editor open. Write the entity list and the grain sentence on paper.
- Keys before types. Decide what identifies a return and what it points at before you decide whether a timestamp is TEXT or INTEGER.
- OLTP before dimensional. The star is derived from the source. If the source cannot record the reason, no dimension can invent it.
- Serve last, and only what someone asked for. One mart, one view, three metrics. A capstone that ships eleven views has misunderstood the exercise.
Conceptual: entities and relationships ★ deliverable: entity list + grain sentence
No editor, no columns. Two questions, and the rest of the session inherits your answers.
Decide: is a return an entity, or a status on the order line? Apply session b1's test - own identity, own lifecycle, own timestamps - and then apply the question that settles it: can the thing be rejected?
Decide the grain: one return per order, or one per line? Say the sentence out loud: "one row is one ___". If the sentence needs the word "and", you have two entities.
Draw the relationships: which existing entity does a return point at, and what is the cardinality in both directions? Write the verbs, the way b1 did on the conceptual model.
Name one question that becomes unanswerable if you get this wrong. That question is your regression test for the next 50 minutes.
Before you commit to a grain, look at the population a return has to attach to. Every one of these orders has three separate product lines, sold by one merchant, at three different prices.
SELECT order_id,
COUNT(*) AS lines_on_order,
COUNT(DISTINCT merchant_id) AS merchants_on_order,
SUM(units) AS units_sold,
ROUND(SUM(net_revenue), 2) AS order_net_revenue
FROM v_sales_line
WHERE is_paid = 1
GROUP BY order_id
HAVING COUNT(*) >= 3
ORDER BY lines_on_order DESC, order_id
LIMIT 8;
LiveThe answer, once you have committed to yours3 min▶
A return is an entity. It has its own identity (a return reference the shopper quotes in support tickets), its own lifecycle (requested, then approved or rejected, then received), its own timestamps (requested at, decided at), its own actor (whoever approved it), and - decisively - it can be rejected. A status value on the order line cannot be rejected, because rejecting it would mean setting the line back to a state that erases the fact that anyone ever asked. The request itself is information the business needs to keep.
The grain is one row per returned order line, not one per order. The data above shows why: a shopper who bought three things and sent one back has produced one return against one line. If your grain were the order, you would have to store which line came back somewhere else, and you would be modelling a line-level fact at order level - the same grain error session b5 diagnosed on the flat export, in a new costume.
- returns : order_items is many-to-one. One line can be returned more than once (a partial return in April, another unit in May), and each is its own event.
- returns : products is many-to-one, carried on the return the same way
order_itemscarriesmerchant_id: a point-in-time capture, so the return still reports the right product if the catalogue is reorganised. - returns : transactions is many-to-one and optional. A rejected return has no refund. An order refunded in one payment can settle three approved return lines.
Self-studyThe trap: return as a flag2 min read▶
The shortcut is order_items.is_returned INTEGER DEFAULT 0. It takes ten seconds, it makes "how many returns" answerable, and it destroys four things at once:
- The reason. "Damaged" and "changed mind" are the same flag, so merchant fault and shopper preference become one number - and the merchant-quality conversation is over before it starts.
- The approver and the decision. A flag has no actor and no outcome. Rejected requests vanish entirely, so the rejection rate cannot be measured and disputes cannot be audited.
- The timing. One flag has one implicit date at best. "How long do we take to decide a return" needs two timestamps, and "returns by request week" needs the return's own date, not the order's.
- Partial and repeat returns. A boolean cannot hold "one of the two units came back", so quantity returned is unrecoverable.
Notice the shape of that list. The flag is not wrong because it is small. It is wrong because it answers exactly one question and forecloses every follow-up, and follow-ups are the entire reason anyone builds a model.
Logical and keys ★ deliverable: attributes, keys, the refund relationship
Attributes, keys and cardinality - the rung that turns an entity list into something an analyst can read. Four decisions, and one of them is the sneakiest on this page.
Key: return_id as a surrogate integer. Nothing outside Bazaar owns a return number, and the shopper-facing reference can be derived - so this is the same call session b4 made for order_id, for the same reason.
Reference: a composite (order_id, line_no) pointing at order_items, whose primary key is exactly that pair. Carry product_id alongside it as a point-in-time capture.
Reason: a constrained set of four codes, never free text. Decide the codes now, because every dashboard, dimension and metric downstream inherits them.
Refund: decide how the return relates to transactions - and decide where the refund amount lives. This is the decision people get wrong.
Run this before you decide. Bazaar already has refunds: negative amounts with status 'refunded'. Look at what the refund transaction can and cannot tell you.
SELECT p.attempt_date, p.order_id, p.txn_id,
ROUND(p.attempt_amount, 2) AS refund_amount,
s.lines_on_order,
ROUND(s.order_net_revenue, 2) AS order_net_revenue
FROM v_payment_attempt p
JOIN (SELECT order_id,
COUNT(*) AS lines_on_order,
SUM(net_revenue) AS order_net_revenue
FROM v_sales_line GROUP BY order_id) s
ON s.order_id = p.order_id
WHERE p.is_refund = 1
ORDER BY p.attempt_date
LIMIT 10;
LiveWhere the refund amount lives, and why it is a trap4 min▶
The refund transaction is at payment-attempt grain. It knows an order was refunded 137.12 and it has no idea which of that order's three lines came back, or why, or whether one unit or four were sent back. So the money is recorded and the event is not, which is why this subject area needs modelling at all rather than a view over transactions.
That gives you two defensible designs, and one indefensible one:
- Defensible - the return points at the refund.
returns.refund_txn_idreferencestransactions(txn_id), nullable because a rejected return has none. The amount lives once, on the transaction. Return value is then derived by joining, and a partial refund covering several return lines needs an allocation rule you write down. - Defensible - the return carries its own allocated amount.
returns.refund_amountholds this line's share, andrefund_txn_idholds the reconciliation key. Line-level return value becomes a simple SUM, at the cost of a copy of the money that can drift. - Indefensible - both, with nothing that reconciles them. The amount on the return and the amount on the transaction, no key linking them and no check comparing them. Two numbers for the same money, and the day they disagree nobody knows which one finance quoted.
Take the second option, because line-level return value is a metric the business will ask for weekly - but only if you also ship the reconciliation. That means the key (refund_txn_id) plus a validation check asserting that the return lines under one refund sum to that refund's amount. Checkpoint 5 runs exactly that check, and it either agrees to the cent or your model is lying.
validate_model.py applies to net_amount = gross_amount - discount_amount, and it is the reason the star can be trusted against the source at all.
Self-studyThe reason set, and why free text is a modeling decision3 min read▶
Four codes: damaged, wrong_item, changed_mind, not_as_described. Snake case, singular concept per code, no spaces, no capitals - the naming standard from session a5, applied without exception because the moment one code is Damaged you own a cleaning step forever.
- A constrained set is a contract with the future. Free text gives you "damaged", "Damaged", "arrived broken", "DMG" and forty singletons, and every one of them is a row that falls out of your GROUP BY.
- Free text has a place, beside the code, not instead of it. Add
reason_note TEXTif support needs the shopper's own words. It is never grouped by, and nothing downstream depends on it. - Adding a fifth code is a modeling event. It changes a CHECK constraint, a dimension row and a metric's meaning. That is a feature: the friction is what stops the set silently sprawling to thirty.
- Never allow a NULL reason. A return with no reason is a row that cannot be grouped, cannot be attributed to merchant fault or shopper choice, and quietly disappears from every reason breakdown. If the source genuinely does not know, that is a code -
unknown- not an absence.
Also decide the status set now: requested, approved, rejected, received. Four values, one lifecycle, and a rejected return that stays in the table forever. If you find yourself wanting a fifth status called refunded, stop - that is the transaction's state, not the return's, and putting it here is how two tables start disagreeing about the same fact.
Physical DDL ★ deliverable: a CREATE TABLE that ships
Now write it, in the style of sql/01_oltp_ddl.sql: every column typed, every rule that can be enforced enforced, and an honest comment where a rule cannot be. The editor below starts on an empty database, so you create everything you query - including the three tables your foreign keys point at.
Types and nullability. Timestamps as ISO-8601 TEXT in UTC, named *_ts. NOT NULL on everything the business always knows at insert time; nullable only where absence is genuinely meaningful - decided_ts before a decision, refund_txn_id when there is no refund.
CHECK the two sets - reason and status - and CHECK quantity_returned > 0. A constraint at the door is worth a hundred cleaning scripts.
Three foreign keys: the composite (order_id, line_no) to order_items, product_id to products, and refund_txn_id to transactions.
One index, chosen deliberately. What will actually be queried? Support looks up returns for an order line; reporting scans by request date. Every index costs write throughput, so name the access path before you add it.
Then break it. Insert a return of 2 units against a line that sold 1, and watch it succeed. Write down where that rule has to live instead.
-- minimal stand-ins for the three parents a return references
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
product_name TEXT NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
order_ts TEXT NOT NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
line_no INTEGER NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price REAL NOT NULL CHECK (unit_price >= 0),
PRIMARY KEY (order_id, line_no)
);
-- GRAIN: one row per returned order line.
CREATE TABLE returns (
return_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
line_no INTEGER NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity_returned INTEGER NOT NULL CHECK (quantity_returned > 0),
reason_code TEXT NOT NULL CHECK (reason_code IN
('damaged','wrong_item','changed_mind','not_as_described')),
status TEXT NOT NULL CHECK (status IN
('requested','approved','rejected','received')),
requested_ts TEXT NOT NULL,
decided_ts TEXT, -- NULL while status = 'requested'
decided_by TEXT, -- the merchant who ruled on it
refund_txn_id INTEGER, -- NULL when rejected
disposition TEXT NOT NULL DEFAULT 'pending' CHECK (disposition IN
('pending','resellable','write_off','not_applicable')),
FOREIGN KEY (order_id, line_no) REFERENCES order_items(order_id, line_no)
);
CREATE INDEX idx_returns_line ON returns(order_id, line_no);
CREATE INDEX idx_returns_req ON returns(requested_ts);
INSERT INTO products VALUES (1051,'Cold Brew Pack'), (1007,'Clay Mask');
INSERT INTO orders VALUES (4,'2026-04-01T13:22:00Z'), (2,'2026-04-01T09:41:00Z');
INSERT INTO order_items VALUES (4,2,1051,1,18.50), (2,1,1007,1,32.90);
INSERT INTO returns
(return_id, order_id, line_no, product_id, quantity_returned, reason_code,
status, requested_ts, decided_ts, decided_by, refund_txn_id, disposition) VALUES
(1, 4, 2, 1051, 1, 'damaged', 'approved', '2026-04-05T09:00:00Z',
'2026-04-05T14:20:00Z', 'M11', 9001, 'write_off'),
(2, 2, 1, 1007, 1, 'changed_mind', 'rejected', '2026-04-06T08:30:00Z',
'2026-04-07T10:00:00Z', 'M02', NULL, 'not_applicable'),
(3, 4, 2, 1051, 2, 'wrong_item', 'approved', '2026-04-08T09:10:00Z',
'2026-04-08T12:00:00Z', 'M11', 9002, 'resellable');
-- the rule no CHECK can see: it needs the other table
SELECT r.return_id, r.order_id, r.line_no, r.reason_code, r.status,
r.quantity_returned, oi.quantity AS quantity_sold,
CASE WHEN r.quantity_returned > oi.quantity
THEN 'VIOLATION' ELSE 'ok' END AS single_return_check
FROM returns r
JOIN order_items oi ON oi.order_id = r.order_id AND oi.line_no = r.line_no
ORDER BY r.return_id;
-- and the harder version: the CUMULATIVE rule across every return on the line
SELECT oi.order_id, oi.line_no, oi.quantity AS quantity_sold,
SUM(r.quantity_returned) AS approved_units_returned
FROM order_items oi
JOIN returns r ON r.order_id = oi.order_id AND r.line_no = oi.line_no
WHERE r.status = 'approved'
GROUP BY oi.order_id, oi.line_no, oi.quantity
HAVING SUM(r.quantity_returned) > oi.quantity;
LiveWhere the over-return rule actually has to live4 min▶
Both queries above found a violation, and the database accepted both rows without complaint. Be precise about why, because "add a constraint" is the wrong instinct here and knowing that is the point of the checkpoint.
- A CHECK constraint sees one row of one table. "Returned units must not exceed units sold" needs
order_items.quantity, which is a different table. In standard SQL the tool for this is an assertion, and essentially no engine implements assertions. So the constraint cannot be written. - The cumulative version is harder still. Even with a magic cross-table CHECK, the real rule is about the sum of every approved return on that line, which is a set, not a row. Returns 1 and 3 above are each individually plausible and jointly impossible.
- Where it goes, in descending order of preference: (1) the application, inside the same transaction that inserts the return, holding a lock on the line - the only place it can be genuinely prevented; (2) a database trigger, which is closer to the data but easy to forget and awkward to test; (3) a validation check that runs on every load and fails loudly, which detects rather than prevents.
- Ship the detection either way. Option 3 is not a substitute for option 1, and option 1 is not an excuse to skip option 3 - the source of bad rows is usually a backfill or a support tool that never went through the application.
Declared is not enforced. SQLite ignores every foreign key in that DDL until PRAGMA foreign_keys = ON is set on the connection, which is session b4's favourite trap and worth re-testing here: the parents exist in this example, so nothing breaks, but a return pointing at a nonexistent line would also insert cleanly. Postgres enforces by default; MySQL depends on the storage engine; a warehouse like Snowflake or BigQuery accepts the syntax and enforces nothing at all, on purpose. Always know which of your constraints are constraints and which are comments.
Self-studyWhich index, and why only one or two2 min read▶
The DDL above adds two, and both are justified by a named access path rather than a hunch:
(order_id, line_no)- support opens an order and asks "has anything here been returned?" This is also the join every load and every validation check uses, so it earns its write cost several times over.requested_ts- the reporting scan. "Returns requested last week" is a range predicate on this column, and without an index it is a full table scan every time the mart rebuilds.
What is deliberately not indexed: reason_code and status, both of which have four values. A four-value column matches roughly a quarter of the table, so the planner will usually scan anyway and you have paid for an index nobody uses. Low cardinality plus a filter that is not selective equals a write tax with no read benefit - which is the whole of session b5's index argument, applied to your own table for the first time.
Dimensional: one dimension, one fact decision ★ deliverable: dim_return_reason + fact_return
The longest checkpoint, because it contains the single decision that separates a model that answers follow-up questions from one that quietly poisons the numbers you already trust.
Build dim_return_reason in the style of dim_payment_method: a surrogate key, the source code as the business key, a readable label, and a modeled grouping the source does not have. Include the -1 unknown member.
Decide the fact. Is fact_return a new fact at return-line grain, or do return measures belong as extra columns on fact_order_item? Decide before you read the answer, and justify it with the two questions from sql/11_fact_ddl.sql.
List the dimensions you reuse. Which of the existing six does fact_return point at, and which date does date_key mean - the order date or the request date?
Split every ratio. No rate is stored. Decide which numerator and denominator columns you need so that return rate, resellable share and refund value can all be computed at any grain.
-- ---------------- dim_return_reason
-- Small dimension, same shape as dim_payment_method: the source code as the
-- business key, a readable label, and a GROUPING the source does not have.
CREATE TABLE dim_return_reason (
return_reason_key INTEGER PRIMARY KEY,
reason_code TEXT NOT NULL UNIQUE,
reason_label TEXT NOT NULL,
reason_group TEXT NOT NULL, -- merchant_fault / shopper_choice
is_merchant_fault INTEGER NOT NULL CHECK (is_merchant_fault IN (0, 1))
);
INSERT INTO dim_return_reason VALUES
(-1, 'unknown', 'Unknown', 'unknown', 0),
(1, 'damaged', 'Damaged in transit', 'merchant_fault', 1),
(2, 'wrong_item', 'Wrong item shipped', 'merchant_fault', 1),
(3, 'not_as_described', 'Not as described', 'merchant_fault', 1),
(4, 'changed_mind', 'Changed mind', 'shopper_choice', 0);
-- a cut-down dim_merchant, standing in for the conformed one you reuse
CREATE TABLE dim_merchant (
merchant_sk INTEGER PRIMARY KEY,
merchant_id TEXT NOT NULL,
merchant_name TEXT NOT NULL,
is_current INTEGER NOT NULL CHECK (is_current IN (0, 1))
);
INSERT INTO dim_merchant VALUES
(-1, 'unknown', 'unknown', 1), (11, 'M11', 'Orchid Grocer', 1),
(4, 'M04', 'Pace Athletics', 1), (1, 'M01', 'Kettle & Co', 1);
-- ---------------- fact_return
-- GRAIN: one row per return line. date_key is the REQUEST date, not the order
-- date - a different date from fact_order_item, which is one of the three
-- reasons this is a separate fact.
CREATE TABLE fact_return (
return_sk INTEGER PRIMARY KEY,
date_key INTEGER NOT NULL,
time_key INTEGER NOT NULL,
user_sk INTEGER NOT NULL,
merchant_sk INTEGER NOT NULL REFERENCES dim_merchant(merchant_sk),
product_sk INTEGER NOT NULL,
return_reason_key INTEGER NOT NULL REFERENCES dim_return_reason(return_reason_key),
return_id INTEGER NOT NULL, -- degenerate
order_id INTEGER NOT NULL, -- degenerate, the drill-down path
line_no INTEGER NOT NULL, -- degenerate
refund_txn_id INTEGER, -- reconciliation key, NULL if rejected
units_returned INTEGER NOT NULL, -- additive
approved_units INTEGER NOT NULL, -- additive, 0 when rejected
resellable_units INTEGER NOT NULL, -- additive
write_off_units INTEGER NOT NULL, -- additive
refund_amount REAL NOT NULL, -- additive, positive, 0 when rejected
is_approved INTEGER NOT NULL CHECK (is_approved IN (0, 1)),
is_rejected INTEGER NOT NULL CHECK (is_rejected IN (0, 1)),
UNIQUE (return_id) -- the grain, enforced
);
CREATE INDEX idx_fret_date ON fact_return(date_key);
CREATE INDEX idx_fret_merchant ON fact_return(merchant_sk);
INSERT INTO fact_return VALUES
(1, 20260405, 9, 12, 11, 51, 1, 1, 4, 2, 9001, 1, 1, 0, 1, 18.50, 1, 0),
(2, 20260406, 8, 27, 4, 24, 2, 2, 7, 2, 9002, 1, 1, 1, 0, 189.00, 1, 0),
(3, 20260407, 15, 33, 1, 11, 4, 3, 17, 1, NULL, 1, 0, 0, 0, 0.00, 0, 1),
(4, 20260409, 11, 48, 1, 6, 3, 4, 15, 2, 9003, 2, 2, 0, 2, 48.00, 1, 0);
-- returns by reason group: merchant fault versus shopper choice, the split the
-- source cannot make and the dimension can
SELECT rr.reason_group, rr.reason_label, rr.is_merchant_fault,
COUNT(*) AS return_lines,
SUM(f.approved_units) AS approved_units,
SUM(f.resellable_units) AS resellable_units,
ROUND(SUM(f.refund_amount), 2) AS return_value
FROM fact_return f
JOIN dim_return_reason rr ON rr.return_reason_key = f.return_reason_key
GROUP BY rr.reason_group, rr.reason_label, rr.is_merchant_fault
ORDER BY return_value DESC;
-- and the same fact through a conformed dimension you did not have to build
SELECT m.merchant_id, m.merchant_name,
SUM(f.is_approved) AS approved_returns,
SUM(f.is_rejected) AS rejected_returns,
SUM(f.approved_units) AS approved_units,
ROUND(SUM(f.refund_amount), 2) AS return_value
FROM fact_return f
JOIN dim_merchant m ON m.merchant_sk = f.merchant_sk
GROUP BY m.merchant_id, m.merchant_name
ORDER BY return_value DESC;
LiveThe answer: a new fact, for three independent reasons4 min▶
fact_return is a new fact table at return-line grain. Any one of these three would settle it; you have all three:
- Different grain. One order line can produce two returns, so return rows do not divide evenly into sales rows. Putting them together fans the sale out, which the diagram above prices at 37.00 instead of 18.50 - the same arithmetic as session b9's fact-to-fact double count.
- Different date.
fact_order_item.date_keyis when the sale happened;fact_return.date_keyis when the return was requested, usually days later and sometimes in a different month. Two dates in one row means one of them is not the row's date, and every time-series query has to guess which. - Different lifecycle, including a state with no money. A return can be rejected. A rejected return is a real event with a real reason that the business must be able to count, and it is not a sale event at all. Return 3 above is that row: units returned, zero approved, zero money.
Dimensions reused, not rebuilt: dim_date (on the request date), dim_time_of_day, dim_user, dim_merchant and dim_product. Five of the six existing dimensions carry straight over, which is the whole return on the conformance investment made in b6 - "returns by merchant tier" and "returns by product category" work the moment the fact lands, with no new dimension and no new join rules. Only dim_return_reason is new, because only the reason is new vocabulary.
On the product and merchant keys: use the same surrogate the sale used, so a return reports the merchant tier and list price that were true at the time of sale. Resolving them as at the return date instead would attribute an April sale's return to June's tier, and the two facts would stop agreeing about the same merchant.
Self-studyThe accumulating-snapshot alternative, and why not today3 min read▶
A return has a lifecycle with milestones: requested, decided, received, refunded. Kimball has a fact type built for exactly that shape - the accumulating snapshot, one row per process instance with a date key per milestone and a lag measure between them, updated in place as the process advances.
It is a genuinely good fit and it is the right answer for some organisations. It is not the answer here, for reasons worth being able to state:
- It is an update-in-place fact. Rows mutate as milestones land, which is a loading pattern (and a cost model) that belongs to
learn-data-warehouse, not to a modeling capstone. - The transaction-grain fact is a strict prerequisite anyway. Build the event fact first; the snapshot is derived from it. Doing it the other way round is how teams end up unable to answer anything the snapshot did not anticipate.
- Bazaar has two milestones, not six. Requested and decided. An accumulating snapshot earns its complexity at five or six milestones with real lag questions between each pair, and pays for itself in shipping-and-fulfilment pipelines rather than here.
Write the decision down either way. "We considered an accumulating snapshot and chose a transaction-grain fact because the lifecycle has two milestones" is the kind of sentence that stops the same debate being reopened every six months - and it belongs in semantic/contract.yaml under known limitations, not in somebody's memory.
Serve and govern ★ deliverable: mart, view, 3 metrics, 2 golden questions
A fact nobody can query is not finished. Publish it the way b8 and b9 published the rest: one mart at the grain the question is asked at, one self-describing view, metric definitions as data, golden questions as a test suite, and the validator green.
The mart: mart_returns_daily at merchant x day grain, carrying return lines, units returned, return value and resellable units - beside the units and revenue that are its denominators. No stored ratios.
The view: v_return_line, one row per return line, grain in the name, business-readable column names, no exposed path to any other fact. Same five rules as sql/40_agent_views.sql.
Three metric rows: return_rate, return_value, resellable_share, each with a definition, an expression, a source view, a grain and a caveat that is actually honest.
Two golden questions, with the SQL you expect and the wrong answer an ungrounded agent would give.
Then run the validator. python python/validate_model.py. All 27 checks green, or you are not done.
Prototype the mart against real orders before you write any DDL for it. The box below invents seven return lines, attributes them to real settled order lines, rolls them up to merchant x day, and then reconciles the return lines against the refund transactions that already exist in Bazaar.
-- seven return lines, attributed to order lines that really settled, and to the
-- refund transactions that really exist (txn 9, 30, 71, 163)
CREATE TABLE returns_line (
return_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
line_no INTEGER NOT NULL,
reason_code TEXT NOT NULL,
units_returned INTEGER NOT NULL,
refund_amount REAL NOT NULL,
resellable_units INTEGER NOT NULL,
refund_txn_id INTEGER NOT NULL
);
INSERT INTO returns_line VALUES
(1, 7, 1, 'damaged', 1, 56.90, 0, 9),
(2, 7, 2, 'wrong_item', 1, 189.00, 1, 9),
(3, 25, 1, 'not_as_described', 1, 29.61, 0, 30),
(4, 25, 2, 'changed_mind', 2, 62.51, 2, 30),
(5, 25, 3, 'changed_mind', 1, 45.00, 1, 30),
(6, 64, 1, 'wrong_item', 1, 129.00, 1, 71),
(7, 154, 1, 'damaged', 1, 24.00, 0, 163);
-- mart_returns_daily, prototyped: merchant x SALE date, so the units sold on
-- the same row are the right denominator for return rate
SELECT m.activity_date AS sale_date, m.merchant_id, m.merchant_name,
m.orders, m.units, m.net_revenue,
COUNT(r.return_id) AS return_lines,
COALESCE(SUM(r.units_returned), 0) AS units_returned,
ROUND(COALESCE(SUM(r.refund_amount), 0), 2) AS return_value,
COALESCE(SUM(r.resellable_units), 0) AS resellable_units
FROM mart_merchant_daily m
LEFT JOIN (SELECT v.order_date, v.merchant_id, r.return_id,
r.units_returned, r.refund_amount, r.resellable_units
FROM returns_line r
JOIN v_sales_line v
ON v.order_id = r.order_id AND v.line_no = r.line_no) r
ON r.merchant_id = m.merchant_id AND r.order_date = m.activity_date
GROUP BY m.activity_date, m.merchant_id, m.merchant_name, m.orders, m.units, m.net_revenue
HAVING COUNT(r.return_id) > 0
ORDER BY sale_date;
-- the reconciliation the design from checkpoint 2 owes you
SELECT ROUND((SELECT SUM(refund_amount) FROM returns_line), 2) AS return_line_total,
ROUND((SELECT -SUM(amount) FROM fact_transaction
WHERE is_refund = 1
AND order_id IN (SELECT DISTINCT order_id FROM returns_line)), 2)
AS refund_txn_total;
LiveThe date decision the mart forces you to make3 min▶
The prototype above joins returns to the mart on the sale date, not the request date, and that is a modeling decision you have to make explicitly rather than by accident:
- By sale date (cohort attribution). "Of the units we sold on 3 April, what share came back?" The numerator and the denominator describe the same units, so the ratio means something. The cost is that the number for recent days keeps rising as returns arrive - it is not final for weeks.
- By request date (operational load). "How many returns landed on the desk on 9 April?" Final the moment the day ends, and the right number for staffing a returns team. But dividing it by the same day's sales is meaningless, because those are different units.
Both are legitimate metrics. Shipping one and calling it "return rate" without saying which is how two teams end up with two numbers and a meeting. Pick the cohort version for return_rate because it is the one leadership means, keep the request-date count as a separate measure, and put the distinction in the caveat column where an agent and a new analyst will both read it.
fact_return and its denominator in fact_order_item. Never join them. Aggregate each to merchant x day first, then divide - which works precisely because both were built from the same conformed dimensions. This is the same rule as b9's forbidden fact-to-fact join, and the reason the mart exists at all.
Now extend the semantic layer. v_metric_definitions has 13 rows; you are adding three. Each caveat should be the sentence you would otherwise have to repeat in Slack every month.
INSERT INTO v_metric_definitions VALUES
('return_rate',
'Share of units sold that were returned and approved, attributed to the sale date.',
'SUM(approved_units) * 1.0 / SUM(units_sold)',
'v_return_line + v_sales_line', 'merchant x sale date',
'Two facts, two grains: aggregate each to merchant x day BEFORE dividing. Never join the facts.'),
('return_value',
'Refund money attributed to approved return lines, as a positive amount.',
'SUM(refund_amount) WHERE is_approved = 1',
'v_return_line', 'return line',
'Sign flipped from fact_transaction, where refunds are negative. Reconcile on refund_txn_id.'),
('resellable_share',
'Share of returned units that came back fit to sell again.',
'SUM(resellable_units) * 1.0 / SUM(approved_units)',
'v_return_line', 'return line',
'Rejected returns have no units, so they leave both sides - they are not counted as zero.');
SELECT metric_name, source_view, grain, caveat
FROM v_metric_definitions
WHERE metric_name IN ('return_rate', 'return_value', 'resellable_share')
ORDER BY metric_name;
SELECT COUNT(*) AS metric_rows FROM v_metric_definitions;
LiveYour two new golden questions3 min▶
The golden set is a test suite for your model's answerability, so a good new question is one that fails without the artifacts you just shipped. Add these two to semantic/golden_questions.jsonl as q21 and q22, each with the SQL you believe is correct and the wrong answer you expect from an ungrounded agent:
| Question | The SQL you expect | The ungrounded failure |
|---|---|---|
| q21 - "Which return reason costs us the most, and is it our fault?" | SELECT reason_group, reason_label, SUM(approved_units), SUM(refund_amount) FROM v_return_line WHERE is_approved = 1 GROUP BY reason_group, reason_label ORDER BY 4 DESC |
Sums refund_amount over every row, including rejected returns, and reports a reason ranking that includes returns nobody ever refunded. |
| q22 - "What is our return rate by merchant for May?" | Two aggregates to merchant grain - approved units from v_return_line, units sold from v_sales_line - joined on merchant_id, then divided. |
Joins the two facts on order_id, fans the sales lines out by the number of returns, and reports a rate that is wrong in both the numerator and the denominator. |
Then extend semantic/schema_card.md with v_return_line and its grain sentence, and semantic/contract.yaml with the new forbidden join, the three metric definitions, the synonyms the business actually says ("RMA", "refund rate", "send-backs") and the known limitation you wrote down in checkpoint 4. Grade with execution match, exactly as b9 did: run both queries, compare result sets to the cent.
Self-studyThe validation checks your subject area has to add3 min read▶
The 27 checks in python/validate_model.py are grouped by the order things actually go wrong, and every group has an obvious returns equivalent. Write yours to the same shape - one row, one column, zero means pass - and add them to the CHECKS list:
- grain - no duplicate
return_idinfact_return; no duplicate(activity_date, merchant_id)inmart_returns_daily. - keys -
dim_return_reason.reason_codeunique and never NULL. This is the check that catches a free-text reason sneaking back in. - integrity - every
fact_return.return_reason_keyresolves indim_return_reason; everymerchant_sk,product_skanddate_keyresolves too. An orphan return silently disappears from every inner join. - additivity -
approved_units + rejected units = units_returned;resellable_units + write_off_unitsnever exceedsapproved_units;refund_amount = 0wheneveris_rejected = 1; no row both approved and rejected. - reconcile - the row count matches
returnsin the source, and the sum ofrefund_amountperrefund_txn_idequals that transaction's amount. This is the checkpoint-2 promise, enforced. - the cross-table rule - approved units returned per order line never exceed units sold. The rule no CHECK could express, detected on every load.
- nulls - no NULL attributes in
dim_return_reason, and no NULLreason_codeanywhere. - gauge - unknown-member rate on
fact_return. It should be 0.0%, the same as every other fact.
Then re-run the whole file. All 27 existing checks must still pass, because you did not touch anything they cover - and if one of them broke, that is the most useful failure of the whole session: it means the new subject area reached into the old model somewhere it should not have.
How to know you got it right ◐ 2 min
Nine things, checkable by someone else without asking you a single question. That last property is the real test: a model whose correctness depends on its author being in the room is not finished.
| Check | What passing looks like |
|---|---|
| 1 · Grain sentence | "One row of returns is one returned line on one order." No "and", no "or". |
| 2 · Rejection survives | You can list rejected returns with their reason and their decision time. Nothing about them is inferred. |
| 3 · Sets are constrained | An INSERT with reason_code = 'Damaged' or status = 'refunded' fails at the door. |
| 4 · The impossible rule is documented | A comment in the DDL says the over-return rule lives in the application, plus a validation check that detects it. |
| 5 · Sales numbers unchanged | SUM(net_amount) on fact_order_item is still 111,906.48 on settled lines. Adding returns moved nothing. |
| 6 · Conformance used | "Returns by merchant tier" and "returns by product category" work with no new dimension, on the surrogate the sale used. |
| 7 · No stored ratio | Nothing anywhere holds a return rate. Numerators and denominators only, dividing at read time. |
| 8 · Reconciliation agrees | Return lines under one refund sum to that refund's amount, to the cent, and a check asserts it on every load. |
| 9 · The validator is green | All 27 original checks pass, your new checks pass, and the unknown-member rate on fact_return is 0.0%. |
LiveCommon wrong turns2 min▶
Four failures account for almost every version of this exercise that goes wrong. Each one runs cleanly, produces numbers, and is discovered months later by an argument in a meeting.
- Return as a flag.
order_items.is_returned. Loses the reason, the approver, the timing, the quantity and every rejected request. Cheap to write, impossible to unwind once six dashboards read it. - The refund amount stored twice with nothing reconciling it. An amount on the return and an amount on the transaction, no key between them and no check comparing them. The day they disagree, both numbers are suspect and neither can be defended.
return_ratestored as a ratio. A stored rate cannot be re-aggregated: averaging daily rates to a month weights a Tuesday with four sales the same as a Saturday with four hundred. Store the two counts and divide at read time, every time.- A NULL reason. One nullable column, and every reason breakdown quietly drops rows while still looking complete.
unknownis a code with a dimension row and a surrogate key; NULL is an absence that inner joins delete.
The fifth wrong turn is scope. The most common way a capstone like this actually fails is not a bad decision - it is eleven views, a second merchant dimension, an accumulating snapshot and a half-finished exchange subject area, none of it validated. The brief asked for four objects, three metric rows and two golden questions. Ship exactly that, green, and then stop. Knowing what to leave out is the senior half of this skill, and no tool will do it for you.
Try it yourself - beyond this session ◐ 40-60 min total
- Add exchanges as a second subject area. A shopper returns one item and receives another, which is two events and possibly zero money. Decide whether that is a new entity, a status on the return, or a pair of linked returns - and write down why, in the contract, before you write DDL.
- Build the accumulating snapshot version of the return lifecycle beside your transaction-grain fact, with a date key per milestone and a
days_to_decisionlag measure. Then answer "has our decision time improved?" from each of the two facts and compare how hard each was. - Take the returns model to your own warehouse, or the nearest equivalent subject area your model does not cover, and run the same five checkpoints on it with the same minute budget. The clock is the lesson.
- Write the breaking-change policy for
v_return_line: what you may add without warning, what needs a deprecation window, and who gets told. Then add a fifth reason code and follow your own policy end to end. - Take one metric your organisation reports as a stored ratio and split it into numerator and denominator. Re-aggregate it to a month both ways and measure how far apart the two answers are. That gap is what this whole track has been about.
Sources covered
The capstone re-applies the whole map rather than adding to it, which is the point of a capstone. Full source map in materials/official-course-map.md. This page covers:
learn-data-warehouse builder sessions 4-6Three questions before you go 🎯 ◐ 90 seconds
1 · Which single fact about a return most decisively makes it an entity rather than a status on the order line?
A status value cannot be rejected: rejecting it would mean putting the line back to a state that erases the fact anyone ever asked. Rejected requests carry a reason, an approver and a decision time, and the rejection rate is a real metric. Own identity, own lifecycle and own timestamps all point the same way, but rejectability is the one that settles it.
2 · Why do return measures go in a new fact_return rather than extra columns on fact_order_item?
One line can be returned twice, so the sales row would have to be stored twice and SUM(net_revenue) would double for that line. The return's date is the request date, not the order date. And a rejected return is a real event with no sale attached to it. Any one of the three would be enough; all three together make it obvious.
3 · Where does "approved units returned must never exceed units sold" have to live?
A CHECK sees one row of one table, and this rule needs order_items.quantity plus the sum of every approved return on that line. Prevention belongs in the transaction that inserts the return; detection belongs in the validator, because backfills and support tools bypass the application. Ship both, and say in the DDL comment which one you relied on.