Skip to content

Guardrails

This page explains the checks that refuse plausible-but-wrong arithmetic: expressions that parse, typecheck, survive code review, and produce a number that is wrong. Seven arithmetic guardrails run as a pure stage over the draft IR after typechecking; six raise, one deliberately does not. They target the bug class where the formula is right, the data is right, and the answer is 3× wrong.

What a guardrail establishes, and what it pointedly does not, is stated in full in what bloomery proves: these checks preserve the semantics you declared, and say nothing about whether the declarations are true.

A second family joined the same stage with data quality: refusals of quality:, dedupe:, quarantine: and reconcile: declarations that cannot mean anything (below). Both families share the definition — a guardrail says the model is wrong, decidable from the spec alone, at compile time. What a rule does to a row at run time is data quality's business, not this stage's.

Errors, never warnings

Every guardrail violation is a compile error. There is no severity setting and no suppression knob — a knob makes "error" negotiable, and these guardrails exist precisely because this class of error gets waved through. All violations across the whole project are collected and raised as a single GuardrailError aggregate, each with a source path, so authors fix a spec in one round trip.

Unit coherence

Operands of + and - must share a unit. Adding a currency to a count is refused; so is any additive arithmetic involving a field whose unit is unknown — a monetary column without a canonical: link has no metadata, and unknown poisons additive arithmetic rather than silently passing. Multiplication and division are exempt: currency × count is how extensive quantities work. Violation: UnitMismatch.

Tax basis

net and gross may never meet in +/-, and unknown alongside a monetary operand is equally refused. A margin computed as unit_price - supplier_cost, where supplier_cost has no catalog link:

TaxBasisMismatch at entity_model: entities.order_item.fields.margin
  '-' combines 'unit_price' (tax_basis: net) with 'supplier_cost'
  (tax_basis: unknown — the field has no canonical: link, so no metadata
  propagates). Arithmetic combining unknown with any basis is refused.
  Fix: link 'supplier_cost' to a catalog canonical field carrying tax_basis,
  or add the field to the catalog.

The frustration of an early unknown refusal is deliberate: it forces catalog linkage exactly where money math happens.

Currency

Two operands with distinct declared ISO-4217 codes may not meet: CurrencyMismatch, with no escape. Unlike tax basis, an absent code is compatible with anything — single-currency projects are the common case, and forcing declaration everywhere would train authors to paste a constant, destroying the signal. Declared-versus-declared is the bug worth refusing.

convert does not waive this rule — there is no token that does, and the one that used to was removed for buying a compile-time "yes" whose only outcome was a run-time failure. What conversion offers instead is an answer: it produces a column the catalog declares in the target currency, and two operands in one currency were never a violation. That needs an fx_rates: relation in the catalog, because a rate is a dated fact and there is nothing to look one up in otherwise; without it the message says so, and names declaring rates or deriving upstream as the two ways forward.

Grain: the fan-out guard

All operands of a derivation must share the derivation's grain, or the expression must contain an explicit aggregation over the finer grain. An order-grain value joined down to line grain without aggregation is duplicated once per line, and every downstream SUM overstates it — there is no "distribute evenly" auto-fix, because allocation is a modeling decision the author must write out. The derivation-level violation is GrainMismatch:

GrainMismatch at entity_model: entities.order_item.fields.landed_cost
  '+' combines 'unit_price' (grain: one row per line on an order) with
  'shipping_cost' from entity 'order' (grain: one row per order), reached via
  relationship 'item_of_order' (many_to_one) with no aggregation step.
  Joined to line grain, shipping_cost is duplicated once per line; any SUM over
  landed_cost overstates shipping by the line count.
  Fix: add an explicit aggregation/allocation over 'order_item', or declare the
  derivation on entity 'order'.

The same guard also runs where fan-out would actually be built: over each mart definition, at compile time. A measure whose grain differs from the mart's grain is a GrainViolation at the declaration site —

GrainViolation at entity_model: marts.order_items.measures.shipping_cost
  measure 'shipping_cost' has grain 'order' (one row per order), coarser than the
  mart's grain 'order_item' (one row per line on an order). Flattened into the mart
  it is duplicated once per line; any SUM overstates shipping by the line count.
  Fix: remove it from this mart's measures, or serve it from an order-grain mart
  (e.g. marts.orders).

— and a flatten: step whose relationship is not many_to_one or one_to_one is FanoutRisk, because a one-to-many flatten multiplies the mart's own rows. The fanout_trap fixture used to produce a 3×-wrong sum at execution time; it now fails at compile time, and the execution assertion is kept because it documents why the compile error exists.

Additivity

Additivity classes are enforced, not advisory:

  • A ratio or non_additive metric may never be materialized as a stored number — not as a column, not as a stored aggregate — because a stored average re-aggregates wrongly. Only its additive components may be stored; the metric is recomputed at query time. Storing one is AdditivityViolation:
AdditivityViolation at metrics: metrics.average_order_value
  metric 'average_order_value' is ratio and may not be materialized as a
  stored number, but entity 'order' stores a column of that name — a stored
  result re-aggregates wrongly — the value read back is not the value the
  components produce (S-0023/D-6). Fix: store the components and rename
  either the column or the metric; this metric is recomputed at query time
  • A non_additive metric declared without a RatioSpec (or equivalent additive decomposition) is NonAdditiveWithoutComponents: with nothing additive to recompute from, the metric could only ever be answered by storing it — which the first rule forbids.
  • A ratio's two halves are one declaration. A ratio: block under any additivity but ratio, and additivity: ratio with no ratio: block, are both InvalidMetricShape: the first stores the quotient the class exists to keep unstored, and the second names no operands to recompute from.
  • A semi_additive metric may only be aggregated over dimensions other than its policy's over: dimension. Summing an inventory balance across time is refused.
  • additivity: additive is a claim, and it is checked. An avg, a median or a count_distinct declared additive is FalseAdditivityClaim — none of them can be rolled up from a coarser result — and so is a sum over a snapshot whose time axis a mart exposes as a dimension. Each refusal names the repair for its own aggregation.
  • A distinct_count metric is count_distinct over the identity it counts, and nothing else. The word exists so no rollup ever re-aggregates the stored count — summing per-group distinct counts double-counts an identity present in several groups — so distinct_count over any other aggregation is FalseAdditivityClaim too, in the other direction: an additive measure wearing a non-additive word.
  • A mart that carries measures must declare at least one date role — MartMissingTimeDimension otherwise. Every measure in the emitted MetricFlow semantic model needs an aggregation time dimension, and without this compile-time check the downstream failure is obscure.

Two numbers make the stakes concrete, and both are hard-coded in the execution suite. An inventory balance of 100, 80, and 90 over three days, filtered to that range, is 90 — not 270: semi-additive over time means "last value", not "sum". An average order value over one store with 10 orders / 100,000 revenue and another with 100 orders / 200,000 is 2727.27 — never 6000 (the average of the two stored averages) and never 12000 (another plausible mis-aggregation): non-additive means recomputed from summed components at the requested grain, every time.

Note the division of labor: the additivity policy — these compile-time refusals — lives in bloomery. The additivity lowering — generating the windowed SQL for a last balance or the SUM(num)/NULLIF(SUM(den), 0) for a ratio — is delegated to the embedded MetricFlow backend, and the numbers above are asserted by executing its generated SQL. MetricFlow lowers additivity; it does not stop you from modeling it wrongly — at 0.212.0, a measure declaring agg: average over a stored average validates clean and answers 55.00, beside a ratio metric over the same rows that answers 32.50 (the reproduction). That refusal stays here.

Which rows the ratio is about

SUM(num) / NULLIF(SUM(den), 0) guards the case where the total denominator is zero. It says nothing about a row whose denominator is zero, and that row is a different failure: a shipment cancelled after the carrier charged for it contributes 40.00 of cost and 0 parcels, so sum-over-sum charges its cost to the parcels somebody else moved. The total denominator is 40, nothing is null, nothing divides by zero, and the answer is wrong.

There are two right answers and they are spelled identically, which is why bloomery refuses rather than choosing:

# "per unit, over the units that exist" — restrict both operands
carrier_cost:
  filter: [{dimension: parcels, op: gt, values: [0]}]
parcels:
  filter: [{dimension: parcels, op: gt, values: [0]}]

# or declare the field positive once, for every metric over it
parcels:
  quality: [{rule: range, min: 1, on_fail: quarantine}]

# "total spend per unit moved, overheads included" — say so
cost_per_parcel:
  additivity: ratio
  ratio: {numerator: carrier_cost, denominator: parcels, includes_zero_denominator: true}

The disposition is what makes the second one a premise: quarantine and fail remove the row from the relation the ratio sums, while flag and repair leave it there, so a rule at either of those asserts nothing about what was summed.

A ratio whose denominator counts rows is not refused. COUNT(order_id) over a required column counts every row the numerator sums, so the premise holds by construction and there is nothing to declare — which is most ratios, and the reason this refusal is rarer than it sounds.

The operands must also agree with each other. A numerator restricted to one row set and a denominator to another is a quotient of two quantities about different things, refused whether or not a zero is involved.

Path conflict: the guardrail that does not raise

When a field has both a direct source column and a satisfiable recorded derivation, any silent choice is wrong — the two can disagree, and whichever the compiler picked, the discrepancy would become invisible. So the compiler emits both: the derived column under the field's name (the recipe is the recorded, auditable decision), a <name>__direct shadow column carrying the direct value, and a reconciliation audit that surfaces row-level disagreement in the target engine.

This is not a missing error — it is the one guardrail where the correct refusal targets the silence, not the spec. Both paths are individually valid, so there is nothing for the author to fix at compile time; raising would force them to delete information to make the error go away, which is strictly worse than measuring the disagreement.

On an entity in the data-quality system the shadow is cast the same way every other column is — NULL on failure, not produce-or-raise — so a direct value that will not cast lands as NULL and the reconciliation audit reports the row as a disagreement, which it is. It used to abort the run with an engine conversion error naming neither the column nor the check.

On an entity merged from several sources the shadow is projected per branch, from that mapping's own path, so the audit compares each row against the direct value its own source carried. What is refused there is disagreement about whether the conflict exists: every mapping producing the column records a path, or none does.

Range sanity

Optional per-field assert: clauses — min, max, not_null, enum membership, regex — lower into audit artifacts that run in the target engine. The guardrail stage validates each clause statically against the field's logical type: min/max on a string field, or a regex on a numeric one, is AssertLoweringError — an assertion that can never run is a silent hole in the audit net.

Data-quality declarations

Declaring what happens to a bad row is run-time work, but declaring it incoherently is a model error, so these refusals live here:

Refusal What the spec got wrong
DedupeTieBreakMissing keep: latest_by with no tie_break — rows sharing a timestamp make the winner arbitrary, and a nondeterministic model makes backfills disagree with the runs they replace
DedupeDispositionConflict A coercible rule weaker than fail on a column the dedupe order reads; an uncastable value there leaves the order undefined, so the weaker disposition is a contradiction rather than a preference
QuarantineRetentionMissing A rule can quarantine but no quarantine: block says for how long. Reject rows hold raw source payloads — this is the sort of thing that is trivial now and a legal problem in eighteen months
IngestionMetadataMissing An entity using quarantine:/dedupe: whose mapping neither maps nor acknowledges _load_id, _ingested_at, _source_row_id
RedactionConflict A redact: path the mapping also reads — you cannot both require a field and destroy it at write time, because replay re-runs the mapping against raw

Nine more refuse as bare GuardrailErrors, without a class of their own: a pattern rule one of the shipped dialect ports has no regex surface for; a dedupe clause ordering by a column the entity does not declare; a referential rule whose via names no relationship, one whose via names a relationship declared from another entity, and one pointing back at its own entity; unknown_member on a non-string foreign key and unknown_member on a composite one; a malformed or unresolvable reconcile side; and a project metric colliding with a name the quality mart owns. The design authority names exactly five new leaves, and minting more would put names in bloomery.errors no RFC decided on — so these carry their argument in the message instead. The errors reference lists each with its trigger.

Where the regex refusals happen is worth being precise about, because most of them are not this stage's. A lookahead — legal under Postgres's POSIX ARE, an aborted run under RE2 on DuckDB and Trino — never reaches the guardrail stage at all: pattern speaks a closed portable subset, so lookaround, backreferences, atomic groups, inline flags and \A/\Z are refused by name at parse, as a SpecParseError, alongside the missing anchors that would otherwise let [0-9]{5} accept abc12345xyz. What is left for this stage is the narrower, mechanical question a compiler that never executes SQL can honestly answer: does each shipped dialect declare a regex surface at all, and does the pattern text reach that dialect's SQL unchanged. The portability claim itself is carried by the subset, not by the round trip — but the principle is the arithmetic guards' applied to text, and it still costs an author one edit where not refusing costs someone a quarter of rows judged by a rule nobody wrote.

The grain and additivity guards are the highest-value part of the package; the wide-mart gold layer shows how the mart design makes the same guarantees structural at query time, and Determinism explains why every one of these refusals is exactly reproducible.