GaurGaur docs
Contracts

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_one or one_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_grains list. hourly_demand.ordered_at declares ["hour", "day", "week", "month"]; asking for year is rejected.
  • Passing time_grain on a non-time dimension (one without semantic_type: "time") is rejected.

Additivity rules

Gaur uses each measure's declared additivity to decide which roll-ups are safe:

  • A non_additive measure can't be summed across groups. Asking Gaur to roll one up is rejected at query time.
  • A semi_additive measure 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_one or one_to_one dimension lookups.
  • Every many_to_one and one_to_one join is on the joined source's declared primary key (all columns, for composite keys).
  • cardinality is declared honestly on every join.
  • additivity is 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.

On this page