Rules and limitations
How Gaur keeps contracts correct, from the single-fact rule to fan-out, chasm, and the join-key rule.
Gaur contracts come with rules, and they exist for one reason: a semantic layer that quietly returns a wrong number is worse than no semantic layer at all. This page covers every rule, why it's there, and how to work within it. Examples use Jaffle Shop's tables and models throughout.
Correct, or rejected
Gaur never auto-corrects an unsafe contract or query. When it can't prove a result is correct, it rejects with a clear message instead of running it.
Rejections come from two places, and the difference matters:
- Authoring-time rejections fire when you create or update the contract. These cover structural rules the validator can prove from the JSON alone: the join-key rule, the single-fact rule, the cardinality declarations.
- Query-time rejections fire when a caller (or you, from the workbench) actually runs a query. Fan-out and chasm fall here, because whether a specific query inflates depends on which measures and dimensions the caller selected.
That second class is important to internalise: a contract that contains a fan-out trap will publish successfully. The error only fires once a query tries to aggregate the at-risk measure. Before shipping, run at least one representative query against the contract from the workbench to catch query-time problems early.
What a "fact" is
Most rules below come back to one idea: the fact.
- A fact is a set of measurable events you aggregate. Its rows are transactions or observations: orders, items, payments, shipments.
- A dimension is a lookup you filter or group by. Its rows describe entities: customers, products, stores, a calendar.
The fact of a contract is whatever its measures aggregate. A source joined
only as a many_to_one or one_to_one lookup, that no measure aggregates,
is a dimension regardless of how many such joins there are.
The single-fact rule
A contract must have exactly one fact. Every measure must aggregate the same source.
That's less restrictive than it sounds. A single-fact contract can:
- Join any number of dimension lookups (
many_to_oneorone_to_one). - Define any number of standard, rolling, and derived measures.
- Expose any number of dimensions drawn from the fact or its lookups.
product_performance is the canonical shape: one fact (raw_items), four
dimension lookups, four measures. Every measure aggregates the items
table; the other four sources only supply attributes.
The join-key rule
Every many_to_one and one_to_one join in a contract has to point at a
table or model that has a
primary key declared, and the join's
on clause has to reference every column of that key.
This is the rule that lets Gaur trust the cardinality you declared. A
many_to_one join on a non-key column wouldn't actually be many-to-one
(the joined side would multiply rows), so the join must be on the declared
key.
one_to_many joins don't require a declared key on the joined side.
They're not claiming uniqueness, so there's nothing to verify.
// Safe: products_clean has primary key (sku); join references it.
{ "source_type": "model", "name": "products_clean", "alias": "p",
"join_type": "inner", "cardinality": "many_to_one",
"on": "i.sku = p.sku" }Composite primary keys
When the joined source's key is composite, the on clause has to
reference every column.
raw_supplies has a composite primary key (id, sku). A many_to_one
join into it would have to match both:
{ "source_type": "table", "name": "raw_supplies", "alias": "su",
"join_type": "inner", "cardinality": "many_to_one",
"on": "i.supply_id = su.id AND i.sku = su.sku" }If only i.supply_id = su.id were in the on clause, the join wouldn't
actually be many-to-one (one supply id has multiple skus). Gaur rejects
the contract at authoring time with a message naming the join.
If a contract fails to publish with a missing-key error, the fix isn't in the contract. Open the table or model, declare its primary key, and the contract publishes.
Fan-out
Fan-out happens when a measure aggregates a fact across a one_to_many
join. The join multiplies the fact's rows, so the aggregate comes out
inflated.
If raw_orders were joined one_to_many to raw_items in a contract, a
2-item order would become 2 rows. SUM(o.order_total) would then count
each order's total twice.
// Query-time rejected: revenue is inflated by the item count.
{
"sources": [
{ "source_type": "table", "name": "raw_orders", "alias": "o" },
{ "source_type": "table", "name": "raw_items", "alias": "i",
"join_type": "left", "cardinality": "one_to_many", "on": "o.id = i.order_id" }
],
"measures": {
"revenue": { "sql": "sum(o.order_total)", "type": "number", "additivity": "additive" }
}
}The contract publishes; the rejection fires when a caller tries to query
revenue. The error looks like:
measure 'revenue' aggregates source 'o', whose rows are multiplied by a
one_to_many join in this contract, so its value would be inflated
(fan-out). Pre-aggregate 'o' to its own grain in a model and join the
result into the contract instead.To fix it, aggregate at the grain that matches the measure. For order
revenue, the fact should be raw_orders (or orders_clean), without
fanning out to items. If you genuinely need item-level and order-level
numbers together, use the
model-first pattern.
Chasm
Chasm happens when a contract has two or more independent facts joined through a shared key. Each fact has many rows per key, so the join becomes a cross-product: every row of fact A pairs with every row of fact B.
If raw_items and raw_payments were both joined on order_id, an order
with 3 items and 2 payments would produce 6 rows. SUM(items.subtotal)
would be counted twice; SUM(payments.value) three times. Both measures
silently wrong.
Gaur rejects any contract with two or more independent fact branches at query time, with a message naming the branches. The fix is the model-first pattern.
A nested chain like raw_orders → raw_items → item_taxes, all
one_to_many, is one fact branch (a single drill-down), not two. Chasm
is about independent facts that share a key, not a single deepening
chain.
Multi-fact via the model-first pattern
Fan-out and chasm both mean the same thing: you're trying to combine more than one fact inside a single contract. A contract isn't the place for that. A model is.
Pre-aggregate each fact to a shared grain
In a model, aggregate each fact on its own (revenue per day, supplies cost per day) so each produces one row per grain value. Keep any column a contract will need for row-level security.
Join the pre-aggregated facts
Still in the model, join the per-fact results on the shared grain. Because each side now has one row per grain value, the join no longer multiplies anything.
Build a single-source contract on the model
The model is now a single, conformed dataset. A contract over it is single-fact and trivially safe.
For a worked Jaffle example combining revenue and supplies cost, see the multi-fact recipe.
Rolling window vs. time grain
A rolling measure
defines a fixed time window: 7d, 4w, 3m. A query against the
contract requests a time grain: day, week, month.
The window and the requested grain have to be compatible. A 7d rolling
measure queried at a month grain collapses to one bucket per month and
is meaningless; Gaur rejects it. Request rolling measures at a grain the
window makes sense for.
Time-grain rejections
Two time_grain failure modes worth knowing:
- The requested grain must be in the dimension's declared
time_grainslist.hourly_demand.ordered_atdeclares["hour", "day", "week", "month"]; asking foryearis rejected. - Passing
time_grainon a non-time dimension (one withoutsemantic_type: "time") is rejected.
Additivity rules
Gaur uses each measure's declared additivity to decide which roll-ups are safe:
- A
non_additivemeasure can't be summed across groups. Asking Gaur to roll one up is rejected at query time. - A
semi_additivemeasure can't be summed across its snapshot grain (usually time). Misusing it across that grain is rejected.
These rejections depend on you declaring additivity honestly. A mislabelled measure defeats the check.
Ratios must be derived measures
A ratio written as a standard measure, like sum(x) / count(y),
aggregates an already-aggregated value, and it's rejected. Write every
ratio as a derived measure
so it's computed correctly at whatever grain the caller asks for.
product_performance.cogs_ratio is the canonical example: a derived
measure with numerator: "total_cogs_usd" and
denominator: "revenue_usd". Gaur computes it post-aggregation, at the
grain of each result row.
Not supported today
Some analytical patterns aren't expressible in a single contract in the current version. Plan around them:
last()-style semi-additive aggregation. Picking the latest value in a period, like a closing balance. Pre-compute it in a model.- Null and zero fill. Empty buckets in a time series aren't automatically filled with zeros. The result contains only buckets that have data; fill gaps in the consuming application.
- Period-over-period. Built-in "vs. previous period" or year-over-year comparisons aren't available. Compute comparisons in the consumer, or model the periods explicitly.
- Timezone normalization. Time dimensions aren't automatically converted between timezones. Normalize timestamps in a model if you need a specific timezone.
The trust boundary
Gaur guarantees the contract layer: a contract that publishes and a query that runs are never silently mis-joined or mis-aggregated. Gaur does not validate the intent of a model's SQL. If a model computes the wrong thing, a contract built on it will faithfully serve the wrong thing.
This is the same boundary every SQL-based analytics tool has. Verify model SQL with exploration before you build contracts on it. The contract layer is sealed; the model layer is on you.
Quick checklist
Before publishing a contract:
- Every measure aggregates the same fact source.
- Extra sources are
many_to_oneorone_to_onedimension lookups. - Every
many_to_oneandone_to_onejoin is on the joined source's declared primary key (all columns, for composite keys). -
cardinalityis declared honestly on every join. -
additivityis declared honestly on every standard measure. - Ratios are derived measures, not standard measures.
- Rolling measures will be queried at a compatible time grain.
- Multi-fact needs go in a model, not crammed into the contract.
Before shipping, run at least one representative query against the contract to catch query-time rejections (fan-out, chasm, additivity violations) before consumers do.