diff --git a/skill-data/data-workflow/references/building-clean-tables.md b/skill-data/data-workflow/references/building-clean-tables.md
index 5ad20cd..fe3f813 100644
--- a/skill-data/data-workflow/references/building-clean-tables.md
+++ b/skill-data/data-workflow/references/building-clean-tables.md
@@ -143,7 +143,7 @@ When refining a built transform _with_ the user, open its inspector so you're lo
**Check the output before handing back — the user can't.** Two passes, in order.
-**Pass 1 — Correctness (did it run right).** After each transform runs, run quick ad-hoc tests against what Phase 0 led you to expect: row counts in the right ballpark, decoded columns readable (no stray codes), linking ids that resolve to the other tables, no column unexpectedly all-null or blown up in count. Treat surprises as bugs to chase, not noise. A table that can't combine with the others — a dropped id, or the same id named two ways — is a silent failure; catch it here.
+**Pass 1 — Correctness (did it run right).** After each transform runs, run quick ad-hoc tests against what Phase 0 led you to expect: row counts in the right ballpark, decoded columns readable (no stray codes), linking ids that resolve to the other tables, no column unexpectedly all-null or blown up in count. Treat surprises as bugs to chase, not noise. A table that can't combine with the others — a dropped id, or the same id named two ways — is a silent failure; catch it here. To go beyond ad-hoc — durable fixture suites, assertions, a coverage matrix, tests the user can re-run — load `transform-test-plan` (`mb skills get transform-test-plan`); its checklist questions double as design questions while you build.
**Pass 2 — Fitness (is it nice to use).** Correct isn't the bar; _usable_ is. `SELECT * FROM
LIMIT 20` and read every column as if you'd never seen the source: would a business reader find each one readable? Smells that say not-yet, even though nothing errored:
diff --git a/skill-data/transform-test-plan/SKILL.md b/skill-data/transform-test-plan/SKILL.md
new file mode 100644
index 0000000..fc4fc46
--- /dev/null
+++ b/skill-data/transform-test-plan/SKILL.md
@@ -0,0 +1,214 @@
+---
+name: transform-test-plan
+description: Derive a comprehensive test plan for transforms and their consuming cards — fixture suites, SQL assertions, hand-derived expected CSVs, and a coverage matrix — from the model's declared design. Covers input partitioning (zero-case, multiplicity, dirty rows), grain / conservation / recomputation / conformance checks, error-vs-warn severity conventions, known-red regression suites, and running the same screens against production tables. Load when the user wants tests planned or written for transforms — "write tests for my transforms", "is my model right", "test plan for this pipeline", "add data quality checks" — whether the model is mid-build or already deployed. Running the tests is the transform-test skill; this one decides what to test.
+allowed-tools: Read, Write, Edit, Bash, AskUserQuestion
+---
+
+# Planning transform tests
+
+Turn a transform chain — and the cards that read it — into fixture suites that
+prove the logic on small known data, assertions that state the model's
+invariants, and a coverage matrix showing what's checked and what's
+deliberately not. Mechanics (the inputs → fixtures → run loop, `--suite`,
+`--source`, card targets) live in the `transform-test` skill
+(`mb skills get transform-test`) — load it before writing suite files; never
+restate it here.
+
+Every check derives from what the model **declares** — detected from its SQL,
+confirmed with its owner — never from conformance to a modeling doctrine. One
+plan serves two moments: while the model is **built**, checks pin each design
+decision; once **deployed**, the same assertion SQL screens production tables
+for anomalies.
+
+## Operating rules
+
+- **Detect, then derive.** Classify what each transform is and which
+ conventions it uses (the checklist); derive checks only from that. Star,
+ one-big-table, partial denormalization — all fine; never flag a style.
+- **Judgment calls go through the checklist.** The session's autonomy setting
+ governs which answers you supply yourself and which you bring to the user —
+ it never makes the checklist a formality. Every answer you supply yourself
+ is recorded in the plan as a stated assumption, paired with the named test
+ (assertion file or expected-CSV cell) that enforces it — reversing the
+ decision then breaks a test, not a paragraph. And regardless of setting,
+ when genuinely unsure, ask — a wrong-but-confident grain poisons every
+ downstream check.
+- **Expected CSVs are derived by hand** from the fixture story and business
+ meaning — never captured from pipeline output, which asserts only that the
+ transform equals itself.
+- **Batteries stay off until declared structure switches them on.** No
+ incremental checks for a full rebuild, no snapshot-density checks without a
+ snapshot, no version-history checks without effective/end/current columns.
+ An empty section beats a speculative one.
+
+## The procedure
+
+1. **Profile the real inputs**: per table, row count; per column,
+ min/max/distinct-count/null incidence; orphan counts across declared links
+ (`mb field summary`, `mb query`). Profiling feeds domains, bounds, null
+ partitions, and key candidates — and every fixture edge cites the
+ real-data condition that warrants it, with its count ("the warehouse has
+ 67 ship-before-order rows").
+2. **Detect.** Read each transform's SQL for: grain candidates (`GROUP BY`
+ keys, the joins' driving table), join types (orphan handling), correlated
+ aggregates (stored aggregates), `_` naming (copies),
+ effective/end/current columns (version history), `now()`/`current_date`
+ (volatile columns), full-rebuild vs. incremental shape. A transform whose
+ grain you cannot state in one phrase is itself a finding — raise it before
+ writing any test.
+3. **Confirm.** Walk `references/checklist.md` — like `checks.md` below, Read
+ it from the directory `mb skills path transform-test-plan` prints (or
+ inline both via `mb skills get transform-test-plan --full --max-bytes 0`;
+ without `--max-bytes 0` the output is cut). Three stages: classify the
+ model, per-table declarations, per-column declarations. Each question
+ carries its detection hint; answer autonomously where the hint resolves,
+ ask where it doesn't. In build-along mode these are design questions —
+ treat an undecided answer as a decision to make together, not a blocker.
+4. **Derive.** Route every output table and column through
+ `references/checks.md` — declared property → assertion shape, fixture
+ implication, CSV convention. Read it in full once per plan; it is the
+ plan's content.
+5. **Design the fixture cast**: one small shared cast per chain (≈5–10 rows
+ per table), human-named rows ("Alice Premium"), every row a named edge —
+ zero-case entities for every outer join and aggregation, ≥2-member groups
+ for every grouping and join, one dirty row per screenable defect, boundary
+ dates. Document it as a table (row → attributes → purpose) in the suites
+ README; downstream suites reuse the cast so expected CSVs stay mutually
+ derivable.
+6. **Hand-derive the expected CSVs**, arithmetic recorded in the README
+ (premium: 3 orders / 350.50 / 3.0). Every fixture row's fate appears in
+ some expected cell. Pin NULL-vs-0-vs-empty for every zero-case row — that
+ cell is the null policy's only enforcement.
+7. **Write the suites**, one directory per target:
+
+ ```
+ suites/
+ README.md # fixture story, arithmetic, conventions, known quirks
+ run-all.sh
+ shared/*.sql # assertions reused verbatim by several suites
+ /
+ suite.yaml # target, sources, inputs, expected, assertions+severities
+ fixtures/*.csv
+ assertions/*.sql
+ expected.csv
+ ```
+
+ Suites are durable deliverables — they live with the project, not in
+ `.scratch`. Each assertion file opens with a 1–3 line contract comment:
+ the invariant, the failure modes it catches ("catches both dropped orders
+ and join fan-out").
+
+ **Reuse assertions across suites.** Chains test end-to-end cheaply, so one
+ invariant often applies at several stages — a domain screen on a column
+ every stage carries, a sign screen valid for a transform and its card.
+ When the SQL is identical, the file lives once in `suites/shared/`,
+ referenced as `file: ../shared/.sql` (paths resolve from the suite's
+ directory, where runs happen). Share only what is truly identical — a
+ "shared" file needing per-suite column renames is worse than two honest
+ copies with their own contract comments.
+8. **Emit the coverage matrix** in the README: rows = output tables with
+ grain; columns = check classes (grain, conservation, recomputation,
+ conformance, domains, referential integrity, temporal, screens); cells
+ name the covering assertion file or fixture row, or state `gap: `.
+ Scan shared-attribute columns across rows for cross-table agreement
+ obligations. Empty cells are honest; silent gaps are not — and that
+ includes structurally absent batteries: "no cards yet", "no volatile
+ columns" belong in the deliberate-gaps list with their revival condition
+ (an end-to-end suite becomes due when the first card is built).
+9. **Prove the tests have teeth.** Once per suite: corrupt one expected-CSV
+ cell, run, confirm the harness points at exactly that cell; revert. Once
+ per chain: perturb one raw-leaf fixture cell, chain-run, confirm exactly
+ the declared descendant cells move.
+10. **Plan the production screens**: the same assertion SQL with
+ `test_output` replaced by the real output table (`mb query`, native).
+ Fixture suites stay deterministic and error-leaning — red means wrong
+ logic. Live screens — red means the data moved — mirror the suites'
+ severity split as two directories: `screens/structural/` (must return
+ zero rows: grain, ties, domains, copy agreement across independently
+ rebuilt tables) and `screens/anomalies/` (expected to fire: tolerated
+ oddities, counts recorded in `baselines.tsv`, drift in either direction
+ failing the run). Keep the tiers separate so neither red pollutes the
+ other.
+
+## Severity: error, or warn-that-fires
+
+**error** = forbidden; failure fails the run. **warn** = tolerated-but-
+surfaced: an oddity the owner lives with (orphan rows, ship-before-order
+dates) gets a warn assertion **designed to fire on every run**, a fixture row
+guaranteeing the fire, and the real-data count in its comment — the tolerance
+stays a conscious choice. A firing warn is not "known-red": reserve that term
+for a live-bug suite held red pending a fix (below). Under budget pressure
+cut business-rule checks first, then cross-table structure checks; never
+single-column screens (domains, ranges, nulls) — cheapest, and the last line.
+
+## When a check exposes a live bug
+
+Non-negotiable: **never soften the suite to green** — expected values state
+correct behavior; matching them to buggy output documents the bug as intended
+— and **surface the finding with its blast radius at both scales**, fixture
+("1530.24 of 1600.74 fixture dollars survive") and warehouse ("908 of 2,050
+orders dropped"). What happens next follows the session's terms, not a fixed
+protocol: propose and apply the fix now (when the user wants it or the
+autonomy setting covers it), or — when the fix must wait — document a
+**known-red suite**: red asserting correct behavior, the minimal fix body in
+`fix/` (ready for `mb transform update --file`), damage in the README.
+Either way, once green the suite stays as the regression guard.
+
+## When the doctrine doesn't apply
+
+The vocabulary follows Kimball's dimensional modeling (grain, additivity,
+conformed attributes, slowly changing dimensions) — precise, widely
+understood terms. Real models are Kimball-inspired at most; no check may
+score adherence:
+
+- Full calendar date dimensions are rare. Never demand one; test date
+ *semantics* — ranges, orderings, volatile derivations.
+- Surrogate keys are doctrine, natural-key joins are practice. Test whichever
+ key the model declares; never flag natural-key joins.
+- "No NULL FKs / no NULL attributes" is doctrine routinely dropped. Null
+ policy is three independent declarations — measures, attributes, FKs —
+ each detected and confirmed, never presumed.
+- One-big-table is legitimate: it still has a grain, its copies still need
+ agreement checks, its functional dependencies still hold.
+- A transform-level `ORDER BY` has no testable effect — output tables carry
+ no row order and the expected-CSV diff is order-insensitive by design.
+ Flag it as probable dead weight (clustering hints aside); never emit an
+ ordering assertion.
+
+## Worked example, condensed
+
+Chain: `orders` + `customers` → *enriched_orders* (order grain; LEFT JOIN
+attaches `customer_name`, `tier`) → *fulfillment_by_tier* (tier grain:
+`order_count`, `revenue`, `avg_days_to_ship` over a `shipping` join) → card
+*Revenue by Tier*.
+
+Cast, 7 orders: two tiers; Alice and Carol with 2 orders each (multiplicity);
+two never shipped (zero-case for the shipping join); order 106 shipped before
+ordered (real oddity, count cited); order 107's customer_id matches no
+customer (orphan).
+
+Derived, per catalog: grain uniqueness on both transforms *and the card*;
+row/amount conservation input → output → card; `avg_days_to_ship` recomputed
+from components (non-additive — never summed); `tier` domain ⊆ {standard,
+premium} at every stage; orphan = keep-with-NULLs → firing warn + expected
+row pinning the NULL pass-through; ship-before-order tolerated → second
+firing warn with the warehouse count; rounding pinned at the card. Expected
+CSVs hand-computed (premium = 3 / 350.50 / 3.0; arithmetic in the README).
+The inner-vs-LEFT-join bug this cast catches — unshipped orders silently
+dropped from revenue — is what the zero-case rows exist for.
+
+## Don't
+
+- Don't write suite files before reading `transform-test` — the header
+ contract, table-id discovery, and `--suite` shape live there.
+- Don't capture an expected CSV from the transform's own output — hand-derive
+ it or it asserts nothing.
+- Don't emit checks for structure the model doesn't declare (snapshot
+ density, version history, bridge weights) — an inapplicable battery buries
+ real findings.
+- Don't let a fixture cast go all-clean — no zero-case, no orphan, no dirty
+ row proves the happy path and nothing else; the bugs live in the edges.
+- Don't blend fixture suites and production screens into one list — their
+ reds mean different things and demand different responses.
+- Don't surface bare check-ids ("per C3…") to the user — name the check in
+ plain words; the ids are for your cross-referencing, not their reading.
diff --git a/skill-data/transform-test-plan/references/checklist.md b/skill-data/transform-test-plan/references/checklist.md
new file mode 100644
index 0000000..e45a714
--- /dev/null
+++ b/skill-data/transform-test-plan/references/checklist.md
@@ -0,0 +1,93 @@
+# Confirmation checklist
+
+The judgment calls a plan depends on, in derivation order. Each question
+carries a **detect** hint — how to answer it from the SQL, the metadata, or
+profiling. Answer autonomously where the hint resolves cleanly; ask where it
+doesn't. Every answer you supply yourself goes into the emitted plan as a
+stated assumption, phrased so the owner can falsify it at a glance
+("Assuming `discount` can never be negative — correct?").
+
+In build-along mode these are design questions: the owner is deciding, not
+recalling. An undecided answer is a decision to make together — surface the
+options and the check each implies (the checks.md entry named in parentheses).
+
+## Stage 1 — classify the model (per transform)
+
+1. **Grain: one row per what?** (G1, G2)
+ Detect: `GROUP BY` keys; otherwise the driving table of the joins. Confirm
+ whenever detection and any written description disagree — that disagreement
+ is itself a finding.
+2. **Full rebuild or incremental?** (F1)
+ Detect: a plain `SELECT` materialization is a full rebuild; merge/append/
+ watermark logic means incremental. Incremental switches on a whole extra
+ fixture battery — never assume it silently.
+3. **Fact-table type — and if periodic snapshot, dense or sparse?** (F2)
+ Detect: event-grain with a single event date → transaction; entity×period
+ grain → periodic snapshot (density is the one yes/no that flips its whole
+ invariant set — always confirm); one row per pipeline occurrence with
+ milestone-date columns → accumulating snapshot.
+
+## Stage 2 — per-table declarations
+
+4. **Conservation ties: output rows/sums tie to which input, with which
+ declared exclusions?** (A2)
+ Detect: `WHERE` clauses and join types name the exclusions (filtered rows,
+ dropped duplicates). Exclusions must be declared, or conservation appears
+ to fail.
+5. **Zero-group policy: does a group with no contributing rows appear (with
+ zeros) or stay absent?** (A5)
+ Detect: driving table of the rollup — grouping the detail can't produce
+ empty groups; joining a dimension first can.
+6. **Orphan handling per join: drop / keep-with-NULLs / default row — and is
+ an orphan tolerated (warn) or forbidden (error)?** (C3, I5)
+ Detect: join type. INNER = drop, LEFT = keep-with-NULLs, COALESCE to a
+ sentinel = default row. Tolerated-vs-forbidden is the owner's call —
+ profiling says whether orphans exist today, not whether they're acceptable.
+7. **Version history: effective/end/current columns anywhere? If not, confirm
+ overwrite-everywhere.** (T1)
+ Detect: column names. Absence means history is silently rewritten in
+ rollups — state that consequence when confirming, not just the mechanism.
+8. **Which consuming cards warrant end-to-end suites?** (P4)
+ Detect: can't — which cards matter is the owner's call. Default to the
+ cards the chain was built to serve.
+
+## Stage 3 — per-column declarations
+
+9. **Each measure: additive, semi-additive, or non-additive?** (A1)
+ Detect: sums and counts are additive; balances and levels are
+ semi-additive; ratios, rates, and unit prices are non-additive. Confirm
+ the ambiguous ones (a "score"? a "quantity on hand"?).
+10. **Legal bounds per measure?** (Q1)
+ Detect: profiling min/max suggests, business meaning decides — can
+ `discount` be negative? can `quantity` be zero? Derived bounds are free
+ (a sum of non-negatives is non-negative).
+11. **Each derived measure: the exact recomputation rule?** (A3)
+ Detect: the SELECT expression is the rule — but rounding, business-day
+ adjustments, and NULL handling are conventions to confirm, not read.
+12. **Which columns are stored aggregates, over which detail and filter?** (A4)
+ Detect: correlated subqueries / joined-aggregate CTEs in the SQL.
+13. **Which categorical columns have closed domains — enumerate; is NULL a
+ member?** (C2)
+ Detect: profiling distinct values / `mb field values` gives today's set;
+ the owner confirms it's closed rather than merely small so far.
+14. **Which columns are denormalized copies, of which owning attribute?** (C1)
+ Detect: `_` naming; any column functionally dependent on a
+ non-grain key. Every hit must be classified: copy (C1), stored aggregate
+ (A4), or smuggled coarser-grain fact (a finding).
+15. **Which many-to-one edges hold within one output table?** (C4)
+ Detect: hierarchy-shaped column pairs (product/category, zip/state).
+ Applies where no owning table is in scope; otherwise C1 covers it.
+16. **Which date orderings are business-guaranteed vs. known-violated and
+ tolerated (with real-data counts)?** (T2)
+ Detect: profiling counts the violations that exist; the owner decides
+ tolerated vs. bug.
+17. **Null policy per column — measures, attributes, FKs separately.** (Q2)
+ Detect: the SQL shows what's produced (COALESCE, CASE); the owner
+ confirms intent — "count of nothing": 0 or NULL? empty date: NULL or
+ sentinel?
+18. **Which columns are volatile across runs?** (T3)
+ Detect: `now()` / `current_date` / run-metadata expressions in the SQL.
+19. **Per tolerated oddity: warn or error — and is anything currently
+ tolerated that should become forbidden?** (Q3)
+ Detect: can't — this is the conscious-choice question the warn convention
+ exists to keep alive.
diff --git a/skill-data/transform-test-plan/references/checks.md b/skill-data/transform-test-plan/references/checks.md
new file mode 100644
index 0000000..6db868b
--- /dev/null
+++ b/skill-data/transform-test-plan/references/checks.md
@@ -0,0 +1,299 @@
+# Check catalog
+
+Every check the plan can derive, grouped by theme. Per entry: **when it
+applies** (the declared property that switches it on — blank means always),
+**assert** (the SQL shape or convention), and **fixtures** (what the cast must
+contain for the check to have teeth). Assertion SQL reads the target's output
+as `test_output` and the seeded input tables under their real names, so
+per-row recomputation joins against inputs are always writable.
+
+Ids (G1, A2, …) are for cross-referencing within the plan documents only —
+never surface them to the user bare.
+
+## Grain & keys
+
+**G1 — Declared grain.** Every output table states "one row per X"; the
+declaration anchors every other check.
+- Applies: always. Elicit or detect (GROUP BY keys; the join's driving table).
+- Assert: nothing directly — G1 is the plan's opening move. A table with no
+ statable grain, or a grain stated inconsistently between docs and SQL, is a
+ finding before any SQL runs.
+- Fixtures: the grain declaration decides the cast's row structure.
+
+**G2 — Grain-key uniqueness.** The grain key stays a key; doubles as the
+fan-out guard for every enriching join.
+- Applies: always — one-big-table, rollups, and stars all have a grain.
+- Assert (error): `SELECT , COUNT(*) FROM test_output GROUP BY
+ HAVING COUNT(*) > 1`. NULL grain values form their own bucket.
+ Emit for every target — transforms *and* cards.
+- Fixtures: a parent with ≥2 children on every join (I4) is what makes this
+ check able to fail.
+
+## Additivity & reconciliation
+
+**A1 — Measure additivity classification.** Additive / semi-additive
+(balances) / non-additive (ratios, rates, unit prices) decides which
+aggregations are valid tests.
+- Applies: every numeric measure; per-column declaration.
+- Assert: routes the measure — additive → A2; semi-additive → sum across
+ non-time slices only (a test summing a balance over time is a bug in the
+ plan); non-additive → A3 recomputation, never reconciled by summing.
+- Fixtures: none directly; one coverage-matrix axis (measure × valid
+ aggregation set).
+
+**A2 — Conservation reconciliation.** Row counts and additive-measure sums tie
+from input to output; catches dropped rows and join double-counting at once.
+- Applies: per declared tie (which input, which declared exclusions).
+- Assert (error): independent scalar subqueries compared with
+ `IS DISTINCT FROM`, returning the mismatched pair:
+ `SELECT (SELECT SUM(t.m) FROM test_output t) AS output_sum,
+ (SELECT SUM(s.m) FROM s) AS input_sum WHERE (…) IS DISTINCT FROM (…)`.
+ Never reconcile via a fact-to-fact join — cardinality is uncontrollable and
+ wrong results are silent. For chains, span raw leaf → final output → card.
+ Where the source has a published authoritative aggregate (an official total),
+ also tie the pipeline's headline figure to it — an out-of-band check no
+ internal bug can satisfy by accident; it validates the semantic reading of
+ the grain, not just the plumbing.
+- Fixtures: amounts chosen so partial survival is visible (distinct values,
+ odd cents).
+
+**A3 — Derived-measure recomputation.** Averages, lags, ratios, rounded
+presentations recompute from their components per row.
+- Applies: every derived column, with its declared rule (rounding, business-day
+ adjustment, NULL handling).
+- Assert (error): `SELECT FROM test_output t WHERE t.
+ IS DISTINCT FROM `.
+ Presentation rules (round to n decimals) are the same shape at the card.
+- Fixtures: component values whose derivation is non-trivial (a NULL in the
+ AVG, a negative lag).
+
+**A4 — Stored aggregates on entity tables.** Lifetime/rollup stats carried on
+an entity-grain table (lifetime_orders, review_count, first_order_date) equal
+recomputation from detail — per row *and* in total.
+- Applies: columns detected as correlated aggregates over a detail table.
+- Assert (error), two per column: per-row — `WHERE t. IS DISTINCT FROM
+ (SELECT COUNT(*)/SUM(…)/MIN(…) FROM d WHERE d. = t.)`;
+ total — the A2 scalar-pair shape. Totals alone cancel offsetting per-row
+ errors; the per-row form is the one that catches them.
+- Fixtures: an entity with several detail rows and an entity with none (I3/I4).
+
+**A5 — Rollup consistency via chain tests.** A layered rollup always agrees
+with its base; fixtures live at raw leaves only.
+- Applies: any transform reading another transform's output.
+- Assert: suite `sources:` names the parent(s); every rollup measure equals
+ the corresponding aggregation over the (scratch) base (A2 shape); group-set
+ equality both directions — `SELECT FROM test_output EXCEPT SELECT
+ DISTINCT FROM ` and the reverse, filtered by the declared
+ zero-group policy.
+- Fixtures: raw-leaf cast only; a group with no contributing rows pins the
+ zero-group policy.
+
+## Fact-table type & load mode
+
+**F1 — Full-rebuild vs. incremental.** One branch switching a whole battery.
+- Applies: per transform, always classified.
+- Full rebuild: battery off; rerun determinism is free.
+- Incremental adds: duplicate-delivery fixture (same source row twice → no
+ double count), gap fixture (skipped period → declared behavior),
+ late-arrival fixture (fact before its dimension row → declared behavior),
+ no-op-change fixture (identical rewrite → no new version), rerun idempotency
+ (run twice → identical output).
+
+**F2 — Fact-table type bundle.** Transaction / periodic snapshot /
+accumulating snapshot each carry a distinct invariant set.
+- Applies: per fact-shaped output, always classified; **transaction** needs
+ nothing beyond G2 + A2 (sparsity is legitimate).
+- Periodic snapshot, if declared *dense*: `COUNT(*) = |entities| × |periods|`
+ (or the declared subset); per-entity gap detection in the period series;
+ inactive-period representation (zero vs NULL) pinned in the expected CSV.
+ If sparse, density checks off — the one yes/no flips the whole set.
+- Accumulating snapshot: milestone dates monotone in pipeline order where set
+ (`WHERE < ` per adjacent pair); unset-milestone default
+ (NULL vs sentinel) pinned in expected CSV; completion flags ∈ {0,1} and
+ consistent with their date's set-ness; lags via A3.
+- Fixtures (accumulating): occurrences at every completion stage — none, some,
+ all milestones.
+
+## Conformance & domains
+
+**C1 — Denormalized-copy agreement.** Every copied attribute agrees with the
+owning table's value for that key.
+- Applies: columns declared as copies (detect: `_` naming; any
+ column functionally dependent on a non-grain key). Each such column must be
+ a declared copy (this check), a stored aggregate (A4), or it's a smuggled
+ coarser-grain fact — a finding: it double-counts under summation.
+- Assert (error): `SELECT t., t., d. FROM test_output t JOIN
+ d ON t. = d. WHERE t. IS DISTINCT FROM
+ d.` — one file per owning table, columns UNION ALL'd if preferred.
+- Scope: inside a chain test the copies are produced by the very join under
+ test, so this join-form is near-tautological there — the C4
+ functional-dependency form plus expected-CSV cell pinning carries the suite.
+ The join-form against the independently materialized owning table is the
+ *drift* check: it belongs in the production screens whenever the tables
+ rebuild independently.
+- Fixtures: copies with distinct values per entity so a crossed join is
+ visible.
+
+**C2 — Closed-domain screen.** A categorical column's values stay inside the
+declared enumeration.
+- Applies: per column declared closed (detect from profiling / `mb field
+ values`; confirm the set and whether NULL is a member).
+- Assert (error): `SELECT ,
FROM test_output WHERE
IS NOT
+ NULL AND
NOT IN ()`.
+- Fixtures: every domain value represented where practical; one out-of-domain
+ input row if the source can produce one (I5).
+
+**C3 — Referential integrity / orphan policy.** Every FK resolves, or the
+declared orphan handling is pinned and surfaced.
+- Applies: per join, conditioned on the declared response — drop /
+ keep-with-NULLs / default row; tolerated or forbidden.
+- Assert, strict (error): `SELECT t. FROM test_output t LEFT JOIN d
+ ON t. = d. WHERE t. IS NOT NULL AND d. IS NULL`.
+ Tolerated (warn, fires by design): select the orphan rows (`WHERE
+ t. IS NULL`), plus the expected CSV pinning the pass-through.
+ Default-row convention adds: distinct unknown keys must not collapse into
+ one output row.
+- Fixtures: one orphan row (I5). Without it the join direction is untested —
+ an inner join silently dropping unmatched rows is the classic bug this
+ catches.
+
+**C4 — Many-to-one consistency.** Each declared many-to-one edge holds within
+the output (product → one category; zip → one state).
+- Applies: per declared edge; where an owning table is in scope, C1 subsumes
+ it — this is the one-big-table variant with nothing to join against.
+- Assert (error): `SELECT t., COUNT(DISTINCT t.) FROM test_output t
+ GROUP BY t. HAVING COUNT(DISTINCT t.) > 1`.
+- Fixtures: a violating input row if the source can produce one (I5), pinning
+ the transform's behavior on dirty input.
+
+## Temporal & version history
+
+**T1 — Change handling per attribute.** How the model treats a changed source
+attribute decides the temporal fixtures.
+- Applies: detect effective/end/current housekeeping columns.
+- Present → version-history battery (error): per durable key exactly one
+ current row; `effective < end` per row; intervals contiguous and
+ non-overlapping; current row's end = the declared far-future default; fact
+ rows join the version whose interval contains the fact date.
+- Absent → confirm overwrite-everywhere as a stated assumption (history is
+ silently rewritten in rollups), and derive the propagation probe: change an
+ attribute in a leaf fixture, chain-run, assert every downstream rollup
+ regrouped — a full rebuild does this for free; assert it.
+- Fixtures: a before/after change pair; for version history, an entity with
+ ≥2 versions and a fact row dated inside each interval.
+
+**T2 — Date-pair ordering.** Business-guaranteed orderings asserted; known
+violations surfaced.
+- Applies: per declared date pair (ordered ≤ shipped ≤ delivered; signup ≤
+ first order), each classified guaranteed vs. tolerated-violated.
+- Assert: guaranteed (error) — `SELECT , , FROM
+ test_output WHERE < `. Tolerated (warn, fires by design) —
+ same shape, with the real-data count in the contract comment.
+- Fixtures: one violating row for every tolerated ordering, its downstream
+ arithmetic (a negative lag inside an average) pinned in the expected CSV.
+
+**T3 — Volatile columns.** Values that change across runs can't sit in an
+expected CSV.
+- Applies: columns derived from `now()`/`current_date` (age), run metadata
+ (load timestamps, batch ids).
+- Assert: route the column to `ignore_columns` in the suite; separately
+ assert its form where warranted (error): `WHERE age NOT BETWEEN 0 AND 120`
+ — assertions still see ignored columns. The stable source column
+ (birth_date) stays exact in the expected CSV.
+- Fixtures: none special; the split is the point — ignore the volatile,
+ assert the stable.
+
+## Screens & severity
+
+**Q1 — Range / sign screens.** Each measure's declared bounds hold.
+- Applies: per bounded measure; bounds from business meaning plus profiling
+ (derived bounds are free: a sum of non-negatives is non-negative).
+- Assert (error): `SELECT ,
FROM test_output WHERE
< OR
+
> ` (one-sided where only one bound exists).
+- Fixtures: boundary values where the bound is business-set.
+
+**Q2 — Null policy, three ways.** Measures, descriptive attributes, and FKs
+carry independent null policies; never presume one from another.
+- Applies: per column, asked separately — "count of nothing": 0 or NULL?
+ empty date: NULL or sentinel? FK: see C3.
+- Assert: declared non-null columns get `WHERE