The compile pipeline¶
This page explains how specs become artifacts: six pure stages, each of which either produces trusted output for the next or refuses with a typed error, meeting at a frozen intermediate representation (IR) that every consumer reads. Emitters and the planner consume the IR, never the specs — which is why the thing that builds a table and the thing that queries it can never disagree about what the table means.
flowchart LR
S["specs<br/>(YAML text)"] --> P[parse] --> R[resolve] --> T[typecheck] --> G[guardrails]
G --> IR[("ProjectIR<br/>frozen, fingerprinted")]
IR --> PL["plan()<br/>spec diff"]
IR --> LE["lower / emit"]
IR --> Q["planner<br/>MetricRequest → QueryPlan"]
LE --> A["SQLMesh · dbt · Cube<br/>artifacts"]
LE --> M["MetricFlow<br/>semantic manifest"]
Q -.->|delegates to| MF["embedded MetricFlow<br/>(render-only)"]
Every stage is a pure function: data structures in, data structures out, no I/O, no
clock, no randomness. A stage never patches over bad input from the one before it —
each either succeeds completely or raises a BloomeryError subclass carrying a source
path into the offending spec node.
The six stages¶
1. Parse¶
YAML text becomes strict, frozen Pydantic models — the five spec kinds plus the
Project container. Parse validates shape, not references: unknown keys, missing
required fields, bad type grammar, and duplicate YAML keys are all refused, because a
typo'd key that is silently ignored is the worst failure mode in a config-driven
system. Failures are batched per document and raised as one SpecParseError listing
every path, so authors fix a spec in one round trip.
2. Resolve¶
The parsed specs become one dependency graph: source columns feed entity fields, entity
fields feed canonical fields through canonical: links, canonical fields feed metrics,
and metrics feed metrics. Resolve refuses dangling references (a mapping targeting a
nonexistent entity, a canonical: link to a field the catalog does not define) and
invalid recorded recipes — the compiler validates the recipe the mapping recorded but
never chooses one itself. All failures are ResolutionErrors, batched; a cycle
anywhere in the graph raises CircularDerivation naming the full cycle path. Resolve
also produces two things no later stage recomputes: the reachable/unreachable metric
report (with the specific missing leaves) and the deterministic topological emission
order, with ties broken lexicographically.
3. Typecheck¶
Every transform chain is walked against a closed, versioned transform whitelist:
str → parse_ts → timestamp → to_utc → timestamp passes; a chain whose terminal type
is not assignable to the declared field type does not. Decimal precision is tracked —
widening is implicit, narrowing must be explicit. An unknown transform name raises
UnknownTransformError naming the closest match; a bad chain raises TypeCheckError.
4. Guardrails¶
The stage that refuses arithmetic which parses, typechecks, and produces a wrong
number: unit and tax-basis coherence, currency mixing, grain fan-out (including
mart-level GrainViolation at the declaration site), and additivity policy. It also
refuses data-quality declarations that cannot mean anything — dedupe without a
tie-break, a quarantine disposition without retention, a redaction that destroys a
mapped path. Violations across the whole project are collected and raised as a single
GuardrailError aggregate. These are always errors, never warnings — the
guardrails page walks each one with its failing spec and exact message.
5. Plan¶
plan(old_ir, new_ir) is a pure structural diff of two IRs. Every change is classified
— ADDITIVE, WIDENING, RENAME, RESTATING, BREAKING — with backfill scope and
downstream impact computed from the dependency edges the IR already carries. The stage
refuses expand/contract violations: dropping or narrowing a field that a live metric
still references raises ContractViolation (a PlanError); deprecation must come
first.
6. Lower and emit¶
The IR becomes target artifacts. Each SELECT is constructed as a SQLGlot AST and
rendered per dialect — SQL is never built by string concatenation or Jinja; Jinja
renders only the envelope (a SQLMesh MODEL (...) block, a dbt config header). A
feature the target cannot express raises UnsupportedByTarget (an EmitError) naming
the entity and feature — nothing silently degrades. Every artifact carries a header
comment stamped with the project fingerprint, so drift between applied artifacts and
specs is detectable downstream.
Lowering is also where declared data quality becomes SQL. An entity carrying rules gains
a dedupe QUALIFY, a single-pass _quality_flags construction, a two-way split into
the entity and its <entity>__reject table, blocking audits for fail rules and for
the conservation law, and a replay MERGE artifact bloomery emits and never runs — see
Data quality.
The IR: the frozen hand-off¶
Everything between guardrails and emission is a single value: ProjectIR, a frozen
dataclass tree designed so the deterministic thing is the only easy thing to write.
- All collections are tuples, explicitly sorted on stable identifiers — never sets, never insertion-order dicts. The only unsorted fields are those whose authored order is meaningful (keys, transform chains, mart flatten steps).
- SQL is stored as
SqlExpr— canonical, dialect-neutral SQLGlot text. The string is the value; dialect-specific rendering happens at emit time from a fresh parse, which makes the IR trivially hashable and its equality independent of SQLGlot object identity. project_fingerprint(ir)is ablm1:-prefixed SHA-256 over a canonical byte encoding of the whole tree. It is the cache key for compilation results, the header stamp in every artifact, and the spec component of the planner's hydration cache key — the determinism page covers what it does and does not promise.
Unreachable metrics are IR members, not log lines: "you can't get margin because cogs
is missing" is a product-facing answer, and it travels with the IR to whatever surface
needs it.
Four ports¶
Everything variable around the pure core is a port — a typing.Protocol an adapter
satisfies without importing a base class.
| Port | Varies over | Knows nothing about |
|---|---|---|
TargetEmitter |
SQLMesh, dbt, Cube, MetricFlow manifest | SQL dialects |
DialectPort |
DuckDB, Postgres, Trino rendering and physical types | targets |
NamingPolicy |
logical name → physical (namespace, relation) |
everything else — the only tenant-shaped seam in the package |
Planner |
the query-planning backend | the caller's identity, connections, execution |
Target and dialect vary independently — SQLMesh-on-Trino and dbt-on-Trino share every line of dialect logic — so collapsing them would produce an N×M explosion of near-duplicate templates. This split is the most load-bearing design decision in the package.
The planner is the fourth port, deliberately not a TargetEmitter: emitters run once
per spec version, the planner answers at request time, thousands of times per second —
different lifecycle, different envelope. Its contract is a pair of frozen types,
MetricRequest in and QueryPlan out, and that contract is the stability boundary:
behind it sits MetricFlowPlanner, which delegates SQL generation to an embedded,
render-only MetricFlow (pinned tightly; it drives internal surfaces with no stability
guarantee, so upgrades are deliberate, canary-tested events). Callers bind to
MetricRequest/QueryPlan and never see a MetricFlow type — errors are translated
into bloomery's taxonomy at the adapter, which is what makes the backend swappable. The
wide-marts page explains what the planner refuses and why.
Determinism is what holds all six stages together — the same specs must produce the same bytes forever — and it gets its own page: Determinism.