Specs and the catalog¶
This page explains bloomery's domain model: how the Catalog holds domain knowledge for a whole vertical, how a tenant's EntityModel resolves against it, how a Mapping records — rather than makes — derivation decisions, and how a MetricSet classifies every measure by additivity. It is the model everything downstream (resolution, guardrails, marts, planning) operates on.
The split that makes the model work is vertical-level versus tenant-level:
flowchart LR
C["Catalog<br/>(one per vertical)"] -->|"canonical: links"| E["EntityModel<br/>(one per tenant)"]
S["bronze source"] --> M["Mapping<br/>(one per source × entity)"] --> E
E --> MS["MetricSet"]
E --> MT["Marts"]
Catalog: the vertical-level domain graph¶
The Catalog is the piece most spec-compiler designs miss, and it is what makes the rest tractable. One Catalog exists per vertical (e-commerce retail, logistics, …), written by the platform operator — never by tenants. It holds:
- Canonical fields — the domain's named quantities, each with a logical type and,
for monetary fields, a
unitandtax_basis. These two annotations drive the guardrails: a field without them isunknown, andunknownin additive arithmetic is a compile error. - Recipes, ordered by reliability — alternative derivation paths to a canonical
field. A recipe is "an alternative path to a node in the graph": if
unit_priceisn't present directly, it can come fromline_total / quantity. - Canonical relationships — the entity graph with cardinalities
(
order_item → order,many_to_one), which the grain guardrail and mart flattening both depend on. - Metric templates — reusable metric definitions with grain and additivity built in.
- The date dimension — bloomery owns it, and it is defined here, once. The SQLMesh
emitter builds the
gold.dim_datetable from this definition, and the MetricFlow semantic manifest declares its time spine pointing at that same relation. One definition, two emissions, no drift.
catalog_version: 3
vertical: ecom_retail
canonical_fields:
discount:
entity: order_item
type: decimal(12,4)
unit: currency
recipes:
- {id: direct, requires: [discount]}
- {id: from_prices, requires: [list_price, sale_price], expr: "list_price - sale_price"}
- {id: from_pct, requires: [sale_price, discount_pct],
expr: "sale_price * discount_pct"}
canonical_relationships:
- {from: order_item, to: order, via: order_id, cardinality: many_to_one}
- {from: order, to: customer, via: customer_id, cardinality: many_to_one}
metric_templates:
gross_revenue:
requires: [unit_price, quantity]
grain: order_item
additivity: additive
agg: sum
expr: "unit_price * quantity"
The Catalog is deliberately not part of a tenant's project. It is passed separately to
compile_project and resolve, because it is shared across every tenant in the
vertical and versioned on its own cadence.
EntityModel: the tenant's resolved subgraph¶
A tenant's EntityModel is the subgraph of the Catalog that resolved against their actual data, plus tenant-native extensions the Catalog never anticipated. Each entity declares its grain (as prose — it appears in error messages), its key, SCD kind, partitioning, and typed fields.
spec_version: 7
entities:
order_item:
grain: one row per line on an order
key: [order_id, line_no]
scd: type1
partition_by: [days(order_date)]
fields:
order_id: {type: string, required: true}
line_no: {type: int, required: true}
unit_price: {type: decimal(12,4), canonical: unit_price}
quantity: {type: int, canonical: quantity}
extensions: {type: variant} # unmapped tail
relationships:
- {name: item_of_order, from: order_item, to: order, via: {order_id: order_id},
cardinality: many_to_one}
canonical: unit_price is the link back to the Catalog. It does two jobs: it makes the
field count toward metric reachability ("this tenant can have gross_revenue because
unit_price and quantity are available"), and it propagates the Catalog's unit and
tax_basis metadata onto the column so the guardrails can check arithmetic. A field
without a canonical: link is tenant-native: legal, queryable, but invisible to
catalog-derived metrics and metadata-free (unknown) in monetary arithmetic.
Mapping: the recorded decision¶
A Mapping describes how one bronze source becomes one entity — key extraction, field extraction via JSONPath-style references, and transform chains drawn from a closed, versioned whitelist.
mapping_version: 3
source: shopify__order_lines
target: order_item
key:
order_id: {from: "$.order_id", transform: [to_string]}
line_no: {from: "$.index", transform: [to_int]}
fields:
unit_price:
recipe: from_total # chosen upstream; recorded for reproducibility
from: {line_total: "$.total", quantity: "$.qty"}
quantity: {from: "$.qty", transform: [to_int]}
The line that matters most is recipe: from_total. Recipe selection — deciding which
of a canonical field's ordered recipes this tenant's data satisfies — happens upstream,
where an LLM may participate. The compiler is handed a decided spec. It validates the
recorded choice exhaustively: the recipe id must exist on the catalog field, and every
name in the recipe's requires must be bound by the mapping's from aliases — exactly,
with unbound requires and surplus aliases both errors.
The compiler validates recipes; it never chooses them
Determinism and auditability both depend on this: the same specs must resolve
identically forever, and a reviewer must see which recipe was used in the spec
itself. The consequence is deliberate: when the Catalog evolves and a recorded
recipe disappears, that is a loud ResolutionError the upstream chooser must
re-decide — never a decision the compiler quietly remakes.
MetricSet: measures and additivity classes¶
The MetricSet declares the tenant's measures, either inline or by reference to a Catalog template. Every metric carries a grain and an additivity class, which decides how it may ever be aggregated or stored:
| Class | Meaning | Carries |
|---|---|---|
additive |
Sums correctly over every dimension (revenue, quantity) | agg, expr |
semi_additive |
Sums over every dimension except one (inventory balance over time) | SemiAdditivePolicy(over, rule) with rule ∈ last/first/avg/max/min |
non_additive |
Never summable (ratios, averages) | RatioSpec(numerator, denominator) over additive components |
metrics:
average_order_value:
requires_metrics: [net_revenue, order_count]
additivity: non_additive
ratio: {numerator: net_revenue, denominator: order_count}
Additivity is not documentation — it is enforced. A non_additive metric may never be
materialized as a stored number, only recomputed from its additive components at query
time; a non_additive metric declared without a RatioSpec (or equivalent additive
decomposition) is refused outright, because with nothing to recompute from it could only
ever be answered by storing it. The policy — what may be stored, what may be summed —
is bloomery's and is checked at compile time; the lowering of these classes into SQL
at query time is delegated to the embedded MetricFlow backend, which can express the
last and first semi-additive rules (avg/max/min raise UnsupportedByTarget
naming the rule). The guardrails page walks the failure modes these
classes prevent.
Marts: the fifth spec kind¶
A project may also carry a marts: document declaring the gold layer: wide, pre-joined
tables at a fixed grain, with dimensions flattened in at build time. Marts are the
serving surface the planner selects from and the emission source for MetricFlow
semantic models — one mart becomes exactly one semantic model. They get their own
page — The wide-mart gold layer.
How the kinds meet¶
Resolution builds one dependency graph over all of it: source columns feed entity fields
(through transform chains and recipe bindings), entity fields feed canonical fields
(through canonical: links), canonical fields feed metrics (through requires), and
metrics feed metrics (through requires_metrics). From that single graph come metric
reachability with specific missing leaves ("you can't get margin because cogs is
missing" — a product-facing answer, not a diagnostic), cycle detection, and the
deterministic emission order. The compile pipeline page follows
the specs through that graph to emitted artifacts.