Spec schemas¶
Field-by-field reference for eight of the ten spec kinds; the two composition documents, exports_version and imports_version, are described where they are consumed, under Composing across projects. Parsing is strict: unknown keys,
duplicate YAML keys, and grammar violations are hard SpecParseErrors, batched per
document with a source path per failure. Parse validates shape and grammar only —
whether references exist is checked at resolution.
Each project document self-identifies by its version key: spec_version (EntityModel),
mapping_version (Mapping), metrics_version (MetricSet), marts_version (MartSet),
steps_version (StepSet), exposures_version (ExposureSet), retrieval_version
(RetrievalSpec). A project holds exactly one
EntityModel, any number of Mappings, and at most one of every other kind. The Catalog
(catalog_version) is not part of a project — load it with load_catalog and pass it
separately.
A version key is required, and it is also how bloomery tells one kind of document from
another. Each is pinned to 1: a document declaring a version bloomery does not
implement is refused, never read as one it does.
Shared grammars¶
| Grammar | Rule | Examples |
|---|---|---|
| Type string | string, int, bool, date, timestamp, variant, decimal(p, s), or vector(scalar, dimensions) |
decimal(12,4), vector(float32, 1536) |
| Partition spec | A bare column, or fn(column) with fn ∈ days/months/years/hours |
days(order_date) — on Trino emitted as day(order_date); a Trino Hive catalog takes no transform, so partition by a date column there |
| Source path | JSONPath-lite: $ followed by dotted identifiers only |
$.customer.id |
| Currency code | Three uppercase letters (ISO 4217) | EUR |
| Zone name | An IANA name: a letter-led segment, optionally /-joined up to three deep. Checked for shape, never against a zone database — the engine is the authority for a zone that reaches SQL |
America/New_York |
| Member name | Any identifier except the reserved names | — |
| Rule name | [a-z0-9_]+ — identifier-constrained so no flag lowering ever needs escaping |
discount_not_exceeding_gross |
| Retention duration | A positive integer (no leading zero, ≤ 5 digits) plus one unit of h / d / w |
90d |
timestamp is UTC. Not "a timestamp in whatever zone the source used" — the type
means an instant, and two doors lead into it: {to_utc: <zone>}, which converts a wall
clock, and zone_in: UTC, which declares that the source's wall clocks already are one.
A wall clock read for its position with neither behind it is refused — see
zone_in:. date carries no
instant and asks nothing.
Reserved names. metric_time may not be used as a field, dimension, or role name —
the planner owns it as the canonical query-time dimension. The generated data-quality
and ingestion-metadata columns are reserved on the same terms: _quality_flags,
_quality_ok, _quality_repairs, _load_id, _ingested_at, _source_row_id,
has_quality_flags. So is _source, the union merge's provenance column — reserved
unconditionally rather than only on merged entities, because a name that is legal until a
second mapping arrives is a trap laid for the change that adds one.
Reserved entity names. An entity may not be called canonical, exposure, mart,
metric, source or step. Those are the six lineage node-id prefixes: a metric is
metric.<name>, a canonical field canonical.<name>, a gold relation mart.<name>, and
an entity field is <entity>.<field> with no prefix at all — so an entity named metric
with a field revenue produces the same id
as a metric named revenue, and bloomery lineage --node metric.revenue can no longer
say which one you meant. Reserved unconditionally, for the same reason _source is: a
name that is legal until someone adds a metric in another file is a trap laid for them.
This binds a step output too, which is named after the last segment of the relation its
wiring binds.
Retention units. Months and years are deliberately absent — they are not fixed
durations, and a retention window that means something different in February is a legal
problem rather than a convenience. Minutes are absent because m would read as either.
Catalog (catalog_version)¶
One per vertical, loaded via load_catalog(text).
| Field | Type | Required | Meaning |
|---|---|---|---|
catalog_version |
int ≥ 1 | yes | Document version key |
vertical |
string | yes | The vertical this catalog describes |
canonical_fields |
map name → CanonicalField | no ({}) |
The domain's named quantities |
canonical_relationships |
list of CanonicalRelationship | no ([]) |
The canonical entity graph |
metric_templates |
map name → MetricTemplate | no ({}) |
Reusable metric definitions |
date_dimension |
DateDimension | no | The vertical-owned calendar |
fx_rates |
FxRates | no | The dated exchange-rate relation convert reads |
FxRates¶
The shape of a rate table the operator supplies; bloomery reads it and never builds it. See transforms for what it is for.
| Field | Type | Required | Meaning |
|---|---|---|---|
relation |
relation name | yes | Bare name, resolved through the naming policy at the silver layer |
from |
column name | yes | The currency a rate converts from |
to |
column name | yes | The currency a rate converts to |
rate |
column name | yes | The multiplier |
valid_from |
column name | yes | Inclusive start of the rate's validity |
valid_to |
column name | yes | Exclusive end, NULL for the live rate — required, because one end is not an interval |
No two roles may name the same column: each reads a different column of the rate relation, and a shared name makes the emitted predicate compare a column against itself.
CanonicalField¶
| Field | Type | Required | Meaning |
|---|---|---|---|
entity |
string | yes | Home entity of the field |
type |
type string | yes | Logical type |
description |
string | no | Carried into semantic-layer emissions |
unit |
currency | count |
no | Drives the unit guardrail; absent = unknown |
tax_basis |
net | gross | unknown |
no | Drives the tax-basis guardrail |
currency |
ISO 4217 code | no | Drives the currency guardrail |
recipes |
list of Recipe | no ([]) |
Alternative derivation paths, ordered by reliability |
Recipe¶
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | The id a mapping records with recipe: |
requires |
list of strings | yes | Names the recipe consumes |
expr |
string | no | Derivation expression over the required names |
CanonicalRelationship¶
| Field | Type | Required | Meaning |
|---|---|---|---|
from |
string | yes | From-entity |
to |
string | yes | To-entity |
via |
string | yes | Join column |
cardinality |
many_to_one | one_to_one | one_to_many |
yes | Relationship cardinality |
MetricTemplate¶
Same shape as a Metric minus template, with additivity required. A project metric that names it via template: merges values with the
metric's own winning.
DateDimension¶
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | no (dim_date) |
Emitted gold relation name |
grain |
day |
no (day) |
Calendar grain (only day today) |
start_year |
int 1–9999 | yes | First calendar year, inclusive |
end_year |
int 1–9999, ≥ start_year |
yes | Last calendar year, inclusive |
One definition emits both the gold.dim_date model and the MetricFlow time-spine
declaration — bounds are calendar years, never a clock.
EntityModel (spec_version)¶
Exactly one per project.
| Field | Type | Required | Meaning |
|---|---|---|---|
spec_version |
int ≥ 1 | yes | Document version key |
entities |
map name → Entity | yes | The project's entities |
relationships |
list of Relationship | no ([]) |
Declared relationships |
reconcile |
list of Reconcile | no ([]) |
Cross-entity reconciliation checks — see Reconcile |
Entity¶
| Field | Type | Required | Meaning |
|---|---|---|---|
grain |
string | yes | Prose grain — appears in error messages and explanations |
key |
list of field names, ≥ 1 | yes | Entity key, authored order kept |
scd |
type1 | type2 |
no (type1) |
Slowly-changing-dimension kind |
partition_by |
list of partition specs | no ([]) |
Physical partitioning |
materialization |
full | incremental_by_key | incremental_by_partition |
no | Explicit wins; default is incremental_by_partition when partition_by is set, else full |
fields |
map name → Field | yes | Typed fields (metric_time reserved) |
quality |
list of entity quality rules | no ([]) |
Row rules — see Entity quality rules |
dedupe |
Dedupe | no | Keep one row per key — see Dedupe |
quarantine |
Quarantine | no | Reject-table policy — see Quarantine |
arrival_lag |
duration (quarantine.retention's grammar) |
no | How late a row may land after its interval ran. Only on an incremental_by_partition entity at scd: type1; refused anywhere else |
arrival_lag reprocesses past days on SQLMesh. The model pins interval_unit 'day' and
sets lookback to the lag in whole days, rounded up (30h is lookback 2), so a late row
lands on a later run rather than never. A cron more frequent than daily on such a model is
refused by SQLMesh at load unless you also set allow_partials. Unset, neither is emitted.
dbt carries nothing: its incremental entity merges its whole select on the key every run, so
a late row already lands.
materialization is derived, never inferred, and that distinction is the rule. An
entity with a partition key gets incremental_by_partition without anyone writing it —
that is a default: visible in the compiled output, and overridable by saying otherwise.
What bloomery will not do is infer the strategy from grain and partition key in the
ambiguous cases, because an inference is a guess the author cannot see, and a wrong guess
here produces a wrong backfill scope that surfaces as missing rows.
bloomery derives defaults; it does not infer intent.
The same line runs through recipes: the compiler validates the recipe a mapping recorded and never chooses one itself. If a specific inference is later shown to be both safe and visible in the emitted artifact, it can be argued as a change — a default carries no such burden, which is why one is cheap and the other is not.
Field¶
| Field | Type | Required | Meaning |
|---|---|---|---|
type |
type string | yes | Logical type |
required |
bool | no (false) |
Whether the mapping must supply it |
canonical |
string | no | Link to a catalog canonical field |
renamed_from |
string | no | One-shot rename annotation for plan() |
assert |
AssertClause | no | Range-sanity assertions, lowered to audits |
AssertClause¶
All members optional; at least one should be present for the clause to do anything.
| Field | Type | Meaning |
|---|---|---|
min / max |
int, decimal, or string | Inclusive bounds; well-typedness against the field's type is checked at the guardrail stage |
not_null |
bool | Column must not be null |
enum |
list of strings/ints | Closed value set |
regex |
string | Value pattern |
not_null and enum lower to builtin audits/tests; min, max, and regex lower to
custom audit artifacts.
Relationship¶
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | yes | Referenced by mart via: flatten steps |
from / to |
string | yes | The two entities |
via |
map from-column → to-column, ≥ 1 | yes | Join columns — a relationship is its join, so an empty map is a SpecParseError |
cardinality |
many_to_one | one_to_one | one_to_many |
yes | Checked by the fan-out guardrail |
imported_from |
string | no | The artifact this relationship was read out of. Its presence is the fact: a relationship carrying it grades ASSUMED and a mart declaring requires_evidence: locked over a column it carries is refused. Written by an importer, not by hand |
Entity quality rules¶
Row rules, discriminated on rule. Both read more than one column, which is why they
sit on the entity rather than on a mapping field.
rule |
Field | Type | Required | Meaning |
|---|---|---|---|---|
expression |
name |
rule name | yes | Reaches _quality_flags and the quality mart, so it is authored, never generated. Refused if it collides with a name generated from the mapping |
expression |
expr |
string | yes | Boolean predicate over the entity's own columns |
expression |
on_fail |
flag | quarantine | fail |
yes | Disposition |
referential |
via |
relationship name | yes | The declared relationship to probe |
referential |
on_missing |
unknown_member | quarantine | flag |
yes | Disposition for a non-null fk with no matching row |
referential carries on_missing, not on_fail: unknown_member is a disposition no
other rule has (the row passes with its fk rewritten to the reserved '__unknown__'
member), and fail is deliberately unavailable — a pipeline-stopping orphan gate is a
reconcile check instead. unknown_member requires a string-typed fk; the relationship's
to side may not be the declaring entity.
Dedupe¶
| Field | Type | Required | Meaning |
|---|---|---|---|
keep |
latest_by |
yes | Closed vocabulary, one value today |
field |
column name | yes | The recency column, ordered descending |
tie_break |
list of column names | no ([]) |
Further sort keys, authored order kept |
Partitions by the entity's key. tie_break is optional in the grammar and mandatory
in a model: its absence under keep: latest_by is the compile error
DedupeTieBreakMissing. The order finishes with _source_row_id, with NULLS LAST
pinned on every sort key, so the winner is unique by construction.
Quarantine¶
| Field | Type | Required | Meaning |
|---|---|---|---|
retention |
retention duration | yes | How long reject rows live; the only deleter |
redact |
list of source paths | no ([]) |
JSONPaths stripped from raw and key_values at write time |
The block is required whenever any rule can quarantine — QuarantineRetentionMissing
otherwise, never a default, because reject rows hold raw source payloads. A redact:
path may not intersect a path the mapping reads (RedactionConflict): replay re-runs the
mapping against raw, and a redacted path is gone by then.
Reconcile¶
Document-level, a sibling of entities: — a check relates two entities and belongs to
neither.
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
rule name | yes | Unique per project; each check emits its own model and audit |
left / right |
reconcile side | yes | The two sides, keyed on the same columns |
tolerance |
quoted decimal ≥ 0 | yes | Absolute tolerance; an unquoted YAML number is a float and refused |
on_fail |
flag | quarantine | fail |
yes | Disposition |
A side is one of two closed shapes:
left: "sum(order_item.line_total) by order_id" # <agg>(<entity>.<column>) by <columns>
right: "order.total_amount" # <entity>.<column>, keyed by that entity's key
Both sides must key on the same columns, since they join on their keys. Anything outside
the grammar is a GuardrailError — a reconcile side is a declared shape, not SQL.
A NULL key is a group, not an absence. The comparison is null-safe throughout,
because the aggregate side's GROUP BY already collects every NULL key into one group
and everything below it has to read that column by the same rule. The emitted model is a
_keys CTE — both sides' key columns, UNIONed — with each side attached by
LEFT JOIN ... IS NOT DISTINCT FROM. A key present on one side only still fails, which
is what the outer join is for, but a NULL group that agrees on both sides passes, as one
row. Neither the entity key nor an aggregate's by columns are forced non-nullable, so
this is reachable from an ordinary project; if a NULL key is itself a data-quality problem
for you, say so with a not_null rule.
The long spelling is not a stylistic choice. FULL OUTER JOIN ... ON a IS NOT DISTINCT
FROM b says the same thing in one line and renders identically on all three dialects,
and PostgreSQL then refuses to run it — its full join is planned as a merge or hash join,
and a null-safe condition is neither. One shape is emitted everywhere rather than a check
that compares NULL keys on two engines out of three.
Mapping (mapping_version)¶
One document per (source, target entity) pair.
| Field | Type | Required | Meaning |
|---|---|---|---|
mapping_version |
int ≥ 1 | yes | Document version key |
source |
string | yes | Bronze relation name |
target |
string | yes | Target entity |
key |
map key-column → KeyField | yes | Key lowering |
fields |
map field → FieldMapping | no ({}) |
Field lowering |
unmapped |
list of source paths | no ([]) |
The explicitly unmapped tail |
freshness |
Freshness | no (absent) | Staleness thresholds for source |
An entity that uses quarantine: or dedupe: requires the bronze ingestion-metadata
columns _load_id, _ingested_at, and _source_row_id. They are reserved names, so a
mapping states they exist by listing them in unmapped:; their absence is
IngestionMetadataMissing. Their values are checked at run time by a generated
blocking audit: none of the three may be null, _source_row_id must be unique per
source row, and _ingested_at must cast to timestamp. The audit is emitted only for
dialects with TRY_CAST; on the others the entity is UnsupportedByTarget.
Freshness¶
A staleness threshold for the mapping's bronze relation. bloomery emits it; the framework
measures it — dbt source freshness runs SELECT MAX(_ingested_at), and nothing in the
compiler reads a clock or touches data.
| Field | Type | Required | Meaning |
|---|---|---|---|
warn_after |
duration | yes | The age at which the source is reported stale |
error_after |
duration | yes | The age at which staleness fails the check |
Durations are the same closed grammar as quarantine.retention — 6h, 24h, 90d,
2w. Both keys are required and neither is defaulted: a source with no freshness: block
gets no threshold at all, because a guessed one would emit an assertion nobody made.
error_after earlier than warn_after is refused at parse — the warning could never
fire. Equal values are accepted; both thresholds then trip together.
Two more refusals come from the guardrail stage, and both follow from one sentence: a threshold is a statement about the relation, not about the mapping that carries it.
- A
freshness:block on a mapping whose target entity declares neitherquarantine:nordedupe:is refused. Only those blocks make_ingested_atmandatory, so without one the emittedloaded_at_fieldwould name a column that may not exist — the project compiles clean and the framework errors when it runs. - Two mappings giving different thresholds to one bronze relation are refused, naming
both. The emitted
sources.ymlholds one entry per relation, so one of them would be silently dropped. Equal thresholds collapse and are not a conflict; a mapping that declares none is not disagreeing with one, so a relation read by a declaring mapping and a silent one is fine.
Only dbt emits anything for a threshold. SQLMesh and Cube model no source object, so there is nothing there to attach one to and nothing is refused.
KeyField¶
| Field | Type | Required | Meaning |
|---|---|---|---|
from |
source path | yes | Where the key column comes from |
transform |
transform chain | no ([]) |
Steps applied in order |
currency_in |
ISO-4217 code, or {column: <name>} |
no | What currency the path's values are in, where the chain converts |
zone_in |
IANA zone name | no | What clock the path's wall clocks were written on — see zone_in: |
FieldMapping — two forms¶
A field mapping is simple or recipe, discriminated on the presence of recipe:
# simple: one source path plus a transform chain
quantity: {from: "$.qty", transform: [to_int]}
# recipe: a recorded catalog recipe id plus alias → path bindings
unit_price:
recipe: from_total
from: {line_total: "$.total", quantity: "$.qty"}
| Form | Field | Type | Required | Meaning |
|---|---|---|---|---|
| simple | from |
source path | yes | Source column path |
| simple | transform |
transform chain | no ([]) |
Steps applied in order |
| simple | quality |
list of field quality rules | no ([]) |
See Field quality rules |
| simple | currency_in |
ISO-4217 code, or {column: <name>} |
no | What the source holds before a convert step — see currency_in: |
| simple | zone_in |
IANA zone name | no | What clock the source writes its wall clocks on. Required where a parsed timestamp's position is read and the chain does not to_utc — see zone_in: |
| recipe | recipe |
string | yes | Catalog recipe id, chosen upstream and recorded here |
| recipe | from |
map alias → source path | yes | Bindings for every name in the recipe's requires — exactly, no more, no fewer |
| recipe | direct |
source path | no | The source also carries the field directly — emits a <name>__direct shadow column and a reconciliation audit |
| recipe | quality |
list of field quality rules | no ([]) |
See Field quality rules |
Transform steps¶
A step is a bare name or a single-key mapping; args normalize to a tuple:
transform: [trim, to_string] # zero-arg steps
transform: [{parse_ts: ISO8601}] # one arg
transform: [{to_decimal: [12, 4]}] # multiple args
The available names are the closed whitelist in the transforms reference; existence is checked at typecheck, not parse.
Field quality rules¶
A closed catalogue, discriminated on rule. New rules are RFC amendments, never config.
Every rule requires on_fail ∈ flag | quarantine | fail — there is no
project-wide default, and there is deliberately no drop and no repair in v1.
rule |
Parameters | Fires when |
|---|---|---|
coercible |
— | The transform chain produced a coercion-failure marker |
not_null |
— | The value is NULL |
range |
min and/or max (int, decimal, or an exact string bound; ≥ 1 of the two) |
The value falls outside a declared bound |
length |
min and/or max (int ≥ 0; ≥ 1 of the two) |
The character count falls outside a declared bound |
pattern |
regex (portable subset) |
The value does not match |
in_enum |
— | The value survived its enum_map chain unmapped |
in_set |
values (list of strings/ints, ≥ 1) |
The value is outside the literal set |
unique |
— | The value repeats within the partition slice |
Notes that change how you write them:
coercibleis implicit and opt-in per entity. An entity joins the quality system by declaringquality:,quarantine:, or any field-levelquality:—dedupe:alone does not. Every mapped field of a quality-carrying entity then gets acoerciblerule atquarantineunless you declare one explicitly to override the disposition. It is forced tofailon any field named bydedupe.field/tie_break(DedupeDispositionConflictotherwise).- Bounds are separate rules.
range/lengthtake one or both bounds, and two bounds needing different dispositions are simply two rules. rangebounds are exact. Write an int, or quote the value: a quoted bound must be an exact decimal ("10000000000","0.01"— sign and fraction allowed, no exponent) or an ISO date/timestamp."nan","inf", and"1e10"are refused at parse: the first two make a comparison that is never true on one engine and always true on another, and the third renders as a float literal, which no emission path may carry.in_enumtakes no values. The admissible set is the chain'senum_maptargets; restating it here would let the two drift.uniqueis per partition slice, in both full and incremental runs — for an unpartitioned entity the slice is the whole table. Cross-partition duplicates are key-baseddedupe's job, in every mode.- Nulls belong to
not_nullandcoercibleonly. Every other rule's violation predicate must be definitively TRUE to fire; a NULL-involved comparison evaluates to SQLUNKNOWNand stays silent.
assert: on an entity field and quality: on a mapping field are different tools:
assert: is "alert me" (an audit that observes), quality: is "act on the row" (the
disposition system that routes). A field may carry both.
The portable regex subset¶
pattern regexes are restricted to what the shipped dialect ports agree on — DuckDB and
Trino run RE2, Postgres runs POSIX ARE. The subset is an allowlist: a pattern is
scanned left to right and anything the list below does not name is refused at parse,
with the construct named in the error.
| Accepted | Refused (a selection — the list is closed, so anything unnamed is refused too) |
|---|---|
Literals and escaped literals (\., \(, \\, \t, \n, \r) |
Backreferences \1, property classes \p{L}, character-code escapes \x41 |
. |
\A, \Z, \z, \b, \B, \G |
Character classes [a-z], [^a-z], with ranges and negation |
POSIX classes [[:alpha:]], collating elements [[.x.]], equivalence classes [[=x=]] |
\d \w \s and \D \W \S |
\D \W \S inside a class — an error on Postgres, legal on RE2 |
Anchors ^ $ |
Anchors inside a group, or mid-alternative |
Quantifiers * + ? {n} {n,} {n,m} |
Lazy a*?, possessive a*+, doubled a** |
Alternation \| |
— |
Non-capturing groups (?:…) |
Capturing groups (…), lookaround (?= (?! (?<= (?<!, named groups (?P< (?<, atomic groups (?>, inline flags (?i), comments (?#, conditionals (?( |
Escapes and character classes are scanned, not substring-matched, so \(?= and [(?=]
are literals rather than lookahead.
Patterns must be anchored, and you write the anchors. [0-9]{5} is refused;
^[0-9]{5}$ is accepted. A SQL regex predicate matches a substring, so the unanchored
form would accept abc12345xyz. Each top-level alternative carries its own pair:
^a$|^b$, not ^a|b$.
Capturing groups are refused in favour of (?:…): a rule is a boolean match and
captures nothing, and numbered groups are what backreferences read.
Two divergences are accepted and documented rather than refused: . excludes newline on
RE2 and includes it on Postgres ARE, and \d/\w/\s are ASCII on RE2 but
locale-defined on ARE.
At the guardrail stage each pattern is additionally checked against the shipped dialect
ports (duckdb, postgres, trino) for a regex surface — a dialect that declares no
regex support, or through which the pattern literal cannot be rendered, is a
GuardrailError. That check is deliberately narrow: bloomery never executes SQL, so
nothing at compile time can prove an engine's regex engine accepts a pattern. The subset
above is what carries the portability claim.
MetricSet (metrics_version)¶
At most one per project.
| Field | Type | Required | Meaning |
|---|---|---|---|
metrics_version |
int ≥ 1 | yes | Document version key |
metrics |
map name → Metric | yes | The project's metrics |
Metric¶
A metric is a template instantiation (template: plus overrides) or fully inline.
| Field | Type | Required | Meaning |
|---|---|---|---|
template |
string | no | Catalog template to instantiate; own values win over the template's |
description |
string | no | Carried into semantic-layer emissions |
requires |
list of canonical field names | no ([]) |
Leaves the metric reads |
requires_metrics |
list of metric names | no ([]) |
Metrics a derived metric reads |
grain |
string | no | Entity grain the metric aggregates from |
additivity |
additive | semi_additive | non_additive | ratio | distinct_count |
effectively yes (inline or via template) | Aggregation class, enforced by guardrails; distinct_count requires agg: count_distinct and an expr naming the counted identity |
agg |
string | no | Aggregation function (sum, count, …) |
expr |
string | no | Expression over the required names |
ratio |
RatioSpec | required by, and requires, additivity: ratio |
Additive decomposition to recompute from |
semi_additive |
SemiAdditivePolicy | with semi_additive |
The dimension and rule |
cumulative |
CumulativeSpec | no | Accumulate this metric's own measure over time |
derived |
DerivedSpec | with non_additive |
Compute the metric from other metrics, optionally read at an offset |
filter |
list of MetricFilter | no ([]) |
Restrict the rows the metric aggregates; clauses are ANDed |
RatioSpec¶
| Field | Type | Required | Meaning |
|---|---|---|---|
numerator / denominator |
metric name | yes | The additive components |
DerivedSpec¶
A metric with no measure of its own, computed by an expression over others. The alias is the mapping key rather than a field, because the expression references it.
| Field | Type | Required | Meaning |
|---|---|---|---|
expr |
string | yes | Expression over the input aliases |
inputs |
map alias → MetricInputSpec | yes (≥ 1) | What each alias reads |
The expression must reference exactly the declared aliases — an unknown one and an
unread one are both refused (InvalidMetricShape). The inputs are also the metric's
dependency edges; they need not be repeated in requires_metrics.
MetricInputSpec¶
| Field | Type | Required | Meaning |
|---|---|---|---|
metric |
metric name | yes | The metric this alias reads |
offset |
MetricOffset | no | How far back it is read |
MetricOffset¶
Exactly one of the two.
| Field | Type | Required | Meaning |
|---|---|---|---|
window |
"<count> <grain>" |
one of | A fixed distance back — "1 year", "7 days" |
to_grain |
day | week | month | quarter | year |
one of | The start of the containing period |
CumulativeSpec¶
Exactly one of the two. The metric keeps its own agg/expr and its own
additivity — those describe the measure, this describes how it accumulates, and
a metric with neither agg nor expr has nothing to accumulate
(InvalidMetricShape). semi_additive is refused here: its over: dimension is
a date role and a window accumulates along that same axis.
| Field | Type | Required | Meaning |
|---|---|---|---|
period_agg |
first | last | average |
no (last) |
How a request coarser than the accumulation collapses each period |
last rather than MetricFlow's own first, and it is this project's only
deliberate divergence from it: on a month totalling 257 a grain_to_date: month
metric reported 100 under first — the running total on the first day, which
is not month-to-date by any reading. The key exists so the default is a default
rather than a decision taken from the author.
| Field | Type | Required | Meaning |
|---|---|---|---|
window |
"<count> <grain>" |
one of | A trailing window — "7 days" |
grain_to_date |
day | week | month | quarter | year |
one of | Accumulate from the start of each period |
MetricFilter¶
A typed predicate, never a SQL string: values are validated against the flattened column's declared logical type and are never cast.
| Field | Type | Required | Meaning |
|---|---|---|---|
dimension |
column name, ^[a-z][a-z0-9_]*$ |
yes | A categorical dimension of the mart carrying the metric |
op |
eq | ne | in | not_in | gt | gte | lt | lte | is_null |
yes | Comparison |
values |
list of scalars | per operator, or column |
One for the comparisons, one bool for is_null, one or more for in/not_in |
column |
column name, ^[a-z][a-z0-9_]*$ |
instead of values |
Another categorical dimension of the same mart to compare against; eq/ne only |
A filter compares its dimension against literals (values:) or against another
dimension (column:) — exactly one of the two. The comparison is admitted only when
both columns are proven to be roles of one dimension member: both flatten steps
declare role_of: naming the same dimension, and both columns carry the same member.
filter:
- {dimension: billing_region, op: eq, column: shipping_region}
Without the declarations the metric is refused with MetricFilterInvalid, naming the
role_of: to add. bloomery explain shows the facts that admitted the comparison
beside the filter. A target that has no spelling for a column comparison refuses it
with UnsupportedByTarget; Cube and MetricFlow both spell it.
Deliberately narrower than the query vocabulary
a request may use: no like/ilike, whose escape language and case-folding
portability buy nothing where the author already knows the values. A date-role
dimension is refused — a metric pinned to one period is a constant, and the time
relation belongs in cumulative: or an offset:.
The name is pattern-constrained where a member name elsewhere is not, and that is
load-bearing rather than tidy: this is the one place a member name reaches a template
that does not quote it — {{ Dimension('<entity>__<name>') }} on MetricFlow,
{CUBE}.<name> on Cube. Every other field name reaches SQL through SQLGlot, which
quotes it.
SemiAdditivePolicy¶
| Field | Type | Required | Meaning |
|---|---|---|---|
over |
dimension name | yes | The dimension the metric is not additive over |
rule |
last | first | avg | min | max |
yes | Rule applied along over |
MartSet (marts_version)¶
At most one per project; a project without marts compiles silver only.
| Field | Type | Required | Meaning |
|---|---|---|---|
marts_version |
int ≥ 1 | yes | Document version key |
marts |
map name → Mart | yes | The gold layer |
rollups |
map name → Rollup | no ({}) |
Aggregates of the marts above; a name a mart already uses is refused |
Mart¶
| Field | Type | Required | Meaning |
|---|---|---|---|
grain |
string | yes | Must equal the base entity's grain; every measure's grain must match it strictly |
base |
entity name | yes | The fact the mart is built from |
reading |
current |
on an scd: type2 base |
Reads only the base's current version (WHERE valid_to IS NULL). Required to base a mart on a historical entity, refused on any other; the mart must then be materialization: full |
flatten |
list of flatten steps | no ([]) |
Applied in authored order; chains flatten transitively |
measures |
list of metric names | no ([]) |
Metrics this mart serves; a mart with measures must declare a date role |
partition_by |
list of partition specs | no ([]) |
Physical partitioning |
materialization |
as Entity | no | Materialization override |
cost_hint |
int ≥ 1 | no (1) |
Tie-breaker when several marts can serve a metric — cheapest wins |
requires_evidence |
locked / assumed |
no (assumed) |
The weakest premise this mart accepts under its measures. assumed accepts every grade a compiling project produces, so omitting the key is byte-identical to writing it; there is no open |
Rollup¶
A mart at a coarser grain than one above it: SELECT keep…, agg(measure) … FROM
<parent> GROUP BY keep…, built once instead of computed per query. It names what
it keeps; what it drops is every other column of the parent, derived rather
than listed, because two statements of one fact will disagree.
rollups:
order_items_monthly:
of: order_items
keep: [order_customer_id, ordered_month]
measures: [gross_revenue]
| Field | Type | Required | Meaning |
|---|---|---|---|
of |
mart name | yes | The mart this aggregates; must be a mart of this document, never another rollup |
keep |
list of parent columns | yes, non-empty | The grouping; each named once, and not every column of the parent |
measures |
list of metric names | yes, non-empty | Metrics this rollup carries; each named once, and each one the parent stores |
partition_by |
list of partition specs | no ([]) |
Physical partitioning |
materialization |
as Entity | no | Materialization override |
There is no grain, no base and no cost_hint. The first two name an entity
and a rollup's rows are identified by keep; the third breaks ties between marts
that can serve a metric, and a rollup is never one of them.
A rollup is never chosen for you. No request is answered from it and no query is redirected to it — reading a rollup instead of the detail is a decision bloomery does not yet make, so the table is there for whatever reads it directly.
An unprovable rollup is refused, not warned about (UnprovableRollup). A
rollup is read instead of the detail table, so a wrong one does not fail — it
answers, quickly and plausibly. Refused: a measure that may not be re-aggregated
over what the rollup drops (distinct_count, semi_additive, non_additive,
snapshot), one the parent does not store, one whose filter: or cumulative:
the single aggregate would drop, and a rollup that keeps every column and so
drops nothing. A ratio measure is carried as its operands and recomputed, which
is what it is on every other target too.
Flatten steps — two forms¶
Discriminated on via vs date:
flatten:
- {via: item_of_order, prefix: order_} # flatten a relationship
- {via: order_of_customer, prefix: customer_, as_of: order_date} # ... as of a date
- {via: order_of_customer, prefix: customer_now_, reading: current} # ... or as it is now
- {date: order_date, role: ordered} # declare a date role
| Form | Field | Type | Required | Meaning |
|---|---|---|---|---|
| via | via |
relationship name | yes | Relationship to flatten; one_to_many is refused (fan-out) |
| via | prefix |
string, non-empty | yes | Prefix on every flattened column — mandatory, collisions are errors |
| via | as_of |
base date/timestamp column | on scd: type2, unless reading |
The anchor: the instant the joined entity is read as of. A historical dimension needs it or reading: current; refused on any other, and beside reading |
| via | reading |
current |
no | Reads only the joined entity's current version (valid_to IS NULL in the join condition) in place of an anchor. Refused on a non-historical entity and beside as_of; the mart must then be materialization: full |
| date | date |
field name | yes | A date/timestamp column of the base entity |
| date | role |
member name | yes | Expands to <role>_day … <role>_year bucket columns (metric_time reserved) |
ExposureSet (exposures_version)¶
At most one per project, and optional. An exposure is something outside the project that
reads it — a dashboard, a notebook, a reverse-ETL sync — declared so the lineage graph
has a sink and plan() can name the consumers a change reaches. Nothing is built for one,
and nothing about one is discovered: see
Declare what reads your project.
| Field | Type | Required | Meaning |
|---|---|---|---|
exposures_version |
1 |
yes | Document version key |
exposures |
map name → Exposure | yes | One entry per consumer; the name is a bare identifier and mints exposure.<name> |
Exposure¶
| Field | Type | Required | Meaning |
|---|---|---|---|
kind |
dashboard / notebook / analysis / ml / application |
yes | dbt's vocabulary verbatim, so the emitted exposure needs no translation |
owner |
string | yes | Who to tell. Emitted as dbt's owner: {email: …} |
depends_on |
DependsOn | yes | What this consumer reads |
url |
string | no | Text. Never fetched, never validated — a compiler that checked it would be making a network request to decide whether a spec parses |
DependsOn¶
| Field | Type | Required | Meaning |
|---|---|---|---|
metrics |
list of metric names | no ([]) |
Metrics this consumer reads |
marts |
list of mart names | no ([]) |
Marts it reads directly. Names a mart, never a rollup |
At least one entry across the two: an exposure that names nothing answers neither "what does this read" nor "who does this change reach", while still reporting clean. A name repeated within a list is refused for the same reason — it would build one graph edge twice and report one consumer twice against a single change.
Shape versus resolution. Parse checks the grammar above. Whether a named metric or
mart exists is a guardrail question, and a name that resolves to nothing is refused there
(DanglingExposure).
exposures_version: 1
exposures:
weekly_revenue_review:
kind: dashboard
owner: analytics@example.com
url: https://bi.example.com/dash/17
depends_on:
metrics: [gross_revenue, order_count]
marts: [order_items]
StepSet (steps_version)¶
At most one per project, and optional — most projects wire no steps. A step is a
referenced implementation: the document names platform-owned code by ref@version and
binds it into the pipeline. It cannot carry a body and cannot name a file to load, which
is what keeps an authored spec from becoming an arbitrary-code-execution surface — the
property comes from the absence of a field, not from validating one.
The manifest describing what a step is — its tier, its declared inputs, outputs and
parameter bounds — is not a spec document. It reaches the compiler as a StepRegistry
passed to compile_project(..., steps=…), so bloomery reads no step files at all. See
Steps: referenced implementations for the manifest side
and the three tiers.
| Field | Type | Required | Meaning |
|---|---|---|---|
steps_version |
1 |
yes | Document version key |
steps |
list of StepWiring | no ([]) |
One wiring per step |
StepWiring¶
| Field | Type | Required | Meaning |
|---|---|---|---|
use |
ref@version |
yes | ^[a-z][a-z0-9_]*@[1-9][0-9]*$. One wiring per ref — a version bump is that step changing, not a second step |
outputs |
map name → relation | yes, ≥ 1 | Binds each declared output to a relation it writes |
inputs |
map name → relation | no ({}) |
Binds each declared input to a relation it reads |
parameters |
map name → string / int / bool / decimal | no ({}) |
Tunables the manifest declares, within the bounds it declares |
seed |
int | no | Required for a seeded step, refused for the other two tiers |
quality |
list of expression rules | no ([]) |
expression rules only; see Entity quality rules for the shape |
applies_to |
map rule name → output | no ({}) |
Which output each rule applies to. Required for every declared rule — a step has several outputs, so "on this step" cannot be lowered |
canonical |
map output → | no ({}) |
Which canonical field a produced column is. Never inferred from a matching name; without it, metrics over a step output are unreachable |
A bound relation is an optionally-namespaced identifier and nothing else
(^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$) — these strings reach generated
source, so the grammar is the second of two locks.
steps_version: 1
steps:
- use: resolve_customers@2
inputs: {shopify: bronze.shopify_customers, stripe: bronze.stripe_customers}
outputs: {customer: silver.customer, links: silver.customer_link}
parameters: {threshold: 0.92}
canonical:
customer: {customer_id: customer_id}
quality:
- {name: confidence_is_sane, rule: expression, expr: "confidence BETWEEN 0 AND 1", on_fail: fail}
applies_to: {confidence_is_sane: links}
Shape versus resolution. Parse checks that canonical and applies_to name outputs
this wiring binds and that every declared rule says which output it applies to. Whether
the manifest actually declares that output, that column or that parameter is a resolution
question — the spec layer has never seen a manifest.
RetrievalSpec (retrieval_version)¶
At most one per project, and optional. A retrieval document declares the logical
retrieval surface over relations the project already builds: which relation is a corpus,
which column holds its vectors, what space those vectors live in, and what a query may
filter on and get back. Nothing is built for one — the artifact is
retrieval_manifest.json, emitted by the retrieval target — and nothing about one is
discovered. See Retrieval for what this is and is not.
No float appears anywhere in the grammar, and no embedding ever reaches the compiler. A space declares a shape (dimensions, scalar, distance) and an identity (which encoder writes the corpus, which reads the query) — the identity is opaque strings compared to strings, never resolved against a provider. Fusion is rank-based, so there is no weight to tune.
| Field | Type | Required | Meaning |
|---|---|---|---|
retrieval_version |
1 |
yes | Document version key |
semantic_spaces |
map name → SemanticSpace | yes | One entry per space; the name is a bare identifier |
profiles |
map name → RetrievalProfile | yes | One entry per retrievable surface |
SemanticSpace¶
A space is what makes two vectors comparable. Two corpora in one space are searchable together; two corpora that merely have the same dimensions are not.
| Field | Type | Required | Meaning |
|---|---|---|---|
dimensions |
int, 1..99999 | yes | Vector length. Must equal the declared length of the profile's vector column |
scalar |
float32 / float16 |
yes | The element type. Names a width, carries no value — the closed set is what keeps a corpus and a query from disagreeing about one silently |
distance |
cosine / dot / l2 |
yes | The metric a runtime scores with. Bloomery computes none of them |
document_encoder |
Encoder | yes | What wrote the corpus. input_kind must be document |
query_encoder |
Encoder | yes | What reads a query. input_kind must be query |
Both sides are declared because they are two different calls, and a corpus written by one
model and queried by another is a silently different space. An encoder under
query_encoder declaring input_kind: document is refused at parse: the document would
read as symmetric while saying it is not.
Encoder¶
| Field | Type | Required | Meaning |
|---|---|---|---|
family |
non-empty string | yes | Who provides the model (openai, cohere, a platform's own name) |
model |
non-empty string | yes | Which model. Never resolved — a compile that asked a provider whether a model exists would read the network |
input_kind |
document / query |
yes | Which side of the space this identity belongs to |
RetrievalProfile¶
| Field | Type | Required | Meaning |
|---|---|---|---|
relation |
CorpusRelation | yes | The corpus: exactly one of a mart or an entity |
grain |
list of column names, ≥ 1 | yes | One row per retrievable item. Must equal the corpus relation's key |
vector |
VectorSide | yes | The dense side |
lexical |
LexicalSide | no | The sparse side. Stands or falls with fusion |
fusion |
Fusion | no | How the two rankings combine. Stands or falls with lexical |
filterable |
list of column names | no ([]) |
Columns a query may restrict on |
return |
list of column names, ≥ 1 | yes | The projection. Spelled return in the document and in the manifest |
CorpusRelation¶
| Field | Type | Required | Meaning |
|---|---|---|---|
mart |
mart name | one of the two | A gold relation — resolves to gold.mart_<name> under the default naming policy |
entity |
entity name | one of the two | A silver relation — resolves to silver.<name> |
analyser |
Analyser | no | What the corpus text was indexed with. The lexical counterpart of a space's document_encoder: refused when it differs from the profile's lexical.analyser, and carried through to the manifest |
Two keys rather than one dotted string, because the two namespaces are separate: a name that resolves in both would otherwise be ambiguous, and neither prefix is a namespace a runtime can use.
VectorSide¶
| Field | Type | Required | Meaning |
|---|---|---|---|
field |
column name | yes | The corpus column holding the vectors. Must be declared vector(<scalar>, <dimensions>), matching the space on both |
space |
space name | yes | A space this document declares |
producer |
Encoder | no | What wrote this corpus, where it is worth stating. Refused when it differs from the space's document_encoder — dimensions agreeing is not spaces agreeing |
LexicalSide¶
| Field | Type | Required | Meaning |
|---|---|---|---|
fields |
list of column names, ≥ 1 | yes | The text columns a lexical ranking reads. Bloomery does not choose the index or the operator |
analyser |
Analyser | no | What tokenises a query. Refused when it differs from the analyser the corpus relation declares — bloomery compares the two names and resolves neither |
Analyser¶
An analyser tokenises a query the way it tokenised the corpus, so there is no
input_kind: two strings, and nothing else.
| Field | Type | Required | Meaning |
|---|---|---|---|
family |
non-empty string | yes | Whose analyser (lucene, a store's own name) |
model |
non-empty string | yes | Which one. Never resolved, for the reason an encoder's model is not |
Fusion¶
| Field | Type | Required | Meaning |
|---|---|---|---|
method |
rrf |
yes | Reciprocal rank fusion, and only that: a rank-based method needs no score calibration, so there is no weight to author and no float to compare |
k |
int ≥ 1 | no (60) |
The rank constant in 1 / (k + rank). 60 is the constant the published method names |
depth |
int ≥ 1 | no (100) |
How many candidates each side contributes. A document below the cut on both sides cannot be recovered by fusing them |
A method name is not a ranking: two consumers honouring rrf with a different constant, or
a different candidate depth, return different orders for one query. The manifest carries the
effective pair — the defaults written out where the document did not name them — so a
consumer that supplies its own is the one that diverged.
retrieval_version: 1
semantic_spaces:
chunk_text:
dimensions: 1536
scalar: float32
distance: cosine
document_encoder: {family: openai, model: text-embedding-3-small, input_kind: document}
query_encoder: {family: openai, model: text-embedding-3-small, input_kind: query}
profiles:
chunk_hybrid:
relation: {entity: chunk, analyser: {family: lucene, model: english}}
grain: [chunk_id]
vector:
field: embedding
space: chunk_text
producer: {family: openai, model: text-embedding-3-small, input_kind: document}
lexical: {fields: [body], analyser: {family: lucene, model: english}}
fusion: {method: rrf, k: 60, depth: 100}
filterable: [document_id]
return: [chunk_id, document_id, body]
Where a vector column comes from. A vector(...) column is assignable only from an
identical vector and is produced by no transform, so no mapping chain can populate one: it
arrives from a step's declared output (produces: {embedding: {type: "vector(float32,
1536)"}}) and nowhere else. Asking a SQL dialect for such a relation is refused —
UnsupportedByTarget, "no physical type for vector(float32, 1536)" — because a declared
vector is not a column a warehouse port emits DDL for. The relation is written by the
platform's step; what bloomery contributes is the contract for querying it.
Shape versus resolution. Parse checks the grammar above, that every profile names a
space this document declares, and that lexical/fusion are both present or both absent.
Whether the corpus relation exists, whether its vector column is a vector of the right
length and scalar, whether the grain is the relation's key, whether the lexical side's
analyser is the one the corpus declares and whether every named column is there are
guardrail questions — see
Retrieval refusals.