Skip to main content
The compiler turns a protocol-neutral semantic query into read-only, dialect-correct SQL. It is completely deterministic: no LLM, no wall-clock, no randomness. Given identical semantics/, contracts/, and query, it emits byte-identical SQL every time.

The semantic query

The input the compiler resolves, produced by adapters (CLI/MCP), never by plain language:
The query references names (metrics, dimensions), never physical tables or columns. Those are resolved against canonical bindings and semantic sources.

Pipeline stages

  1. Bind the principal (only when a tenancy/role policy is loaded). Resolve the caller’s Principal into an EffectivePolicy before any metric resolution. If tenancy is active and no tenant resolved, fail immediately with TENANT_UNRESOLVED: no partial work, no error message that could reveal whether a named metric exists.
  2. Resolve metrics. Map each metric name → canonical binding → source/measure (or composite components). Unknown/ambiguous → UNRESOLVED/AMBIGUOUS with candidates. A metric outside the caller’s effective policy is folded into UNRESOLVED here too, rather than a distinct forbidden code. See Tenancy & access control.
  3. Resolve dimensions & filters. Bind to columns on the owning source or a join-reachable source. Unreachable → UNREACHABLE.
  4. Plan the join graph. From the metric’s source, find the minimal join path to every referenced source using only declared joins. No path → UNREACHABLE. More than one valid path → AMBIGUOUS_JOIN_PATH. The compiler never guesses a shortest path or invents a cross join.
Stage 2b: inject tenant predicates (only under an active tenancy policy). For every source actually joined into the leaf’s plan, not just the metric’s owning source, AND <source>.<column> = :tenant into the leaf’s WHERE, ahead of query filters, population_filter, and guardrails. A source declared neither scoped nor shared → TENANT_SCOPE_MISSING. Built as a real AST node bound to the tenant value, never by string interpolation, so a crafted filter cannot escape its own predicate.
  1. Fanout & additivity analysis. Detect when a join fans out the grain relative to a measure’s source grain, and dispatch to the binding’s compilation strategy (below).
  2. Apply finality & coalescing. If the metric has a finality rule, select the right source(s) for the requested time window and tag output rows final/provisional.
  3. Enforce guardrails. AND-in mandatory filters, apply restrict_source for the active context. severity: error blocks, but warn annotates.
  4. Emit SQL. Build a dialect-neutral AST, then transpile via the dialect adapter. Read-only (SELECT) only, by construction.
  5. Attach result attributes. Resolved bindings, guardrails fired, provisional/final mix, per-source freshness, and additivity handling applied. Under an active tenancy policy, also a metadata.scope block (tenant, scoped/shared sources, roles, tenancy_exempt) derived from the predicates actually emitted in stage 2b, not restated from the policy.
  6. Assertion check (in benchmark/CI mode). Run the emitted SQL and compare to the matching assertion’s expected value. Divergence beyond tolerance fails. Runs tenant-exempt against the full, unfiltered dataset, the population an assertion’s expect was calibrated against, independent of any tenancy policy.
Output shape:
Errors are always structured (code, message, candidates?), never free text, so a caller can act on them programmatically instead of parsing prose.

No guessing

Stage 2 and stage 3 share one rule: if there is more than one way to satisfy a query, the compiler refuses rather than picking one silently. It doesn’t matter that a join could be inferred. If the semantic model doesn’t say which one, the compiler asks instead of guessing. A concrete case: a car-rental model where rentals joins locations twice (once as pickup, once as dropoff) and country is a declared dimension on both locations and customers. Asking for country without saying which one is ambiguous:
The candidates list is exact and actionable: pass one back to disambiguate, and the compiler joins precisely that path, nothing more:
Only the pickup join was added: customers and dropoff never appear in the SQL, because nothing in the query needed them. The same discipline applies one level up: when a join path itself has more than one valid route (AMBIGUOUS_JOIN_PATH), the compiler returns candidate routes and expects via to pick one. See Resolving ambiguous for both cases end to end.

Fanout & additivity

A join can multiply rows relative to a measure’s grain (one-to-many, many-to-many). What’s safe to do about that depends entirely on the measure’s compilation strategy, driven by the binding’s kind (see Contracts & guardrails): The unifying rule behind the composable strategies is aggregate first, combine last: division, weighting, and ratios happen on the aggregated result, never on raw rows. Where no strategy can guarantee correctness, the compiler refuses with UNSUPPORTED_MEASURE or FANOUT_UNSAFE and a rationale, rather than emit a silently wrong number. When a metric is composed from parts, the result inherits the most conservative signal across them: stale if any component is stale, provisional if any component is provisional, and guardrails_fired is the union across every leaf.

Dialect adapter

The compiler builds one dialect-neutral SQLGlot AST. A dialect adapter transpiles it to the target engine: type mapping, identifier quoting, LIMIT injection, and the read-only guarantee. Adding a database and supporting its SQL dialect are independent concerns. Dialects shipped today: PostgreSQL, DuckDB, SQLite, matching the queryable connectors. An unrecognized dialect name that SQLGlot itself understands still works via a generic adapter. Anything else falls back to the PostgreSQL adapter to preserve existing behavior.

Determinism & headless

No part of the compiler consults an LLM or the wall clock (beyond an explicit as_of on relative dates). In headless/CI invocation, each error class maps to a distinct process exit code (UNRESOLVED, AMBIGUOUS, UNREACHABLE, AMBIGUOUS_JOIN_PATH, UNSUPPORTED_MEASURE, FANOUT_UNSAFE, GUARDRAIL_BLOCK, VALIDATION_FAILED, ASSERTION_FAILED), see canonic assert for how this backs the accuracy CI gate.