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, Redshift, MySQL, ClickHouse, Snowflake, Databricks, DuckDB, SQLite, matching the queryable connectors. A dialect name with no registered adapter fails with UnsupportedDialectError (exit 13) instead of silently compiling the wrong SQL flavor. Snowflake supports PERCENTILE_CONT natively. Databricks does too, and the Databricks adapter keeps it exact rather than letting sqlglot rewrite it to the approximate PERCENTILE_APPROX. Because every identifier is quoted, source and column names must match the case Snowflake stores, which is upper case unless the object was created with a quoted lower-case name. MySQL has no DATE_TRUNC and no ordered-set percentile aggregate. The MySQL adapter renders each granularity as explicit date arithmetic, with weeks starting on Monday, and computes percentiles with the same nearest-rank fallback as SQLite. ClickHouse fills the unmatched side of a LEFT JOIN with default values instead of NULL unless join_use_nulls is on, so the ClickHouse adapter appends SETTINGS join_use_nulls = 1 to every query with a join. It casts the numerator of each division to Float64, because a Decimal division would truncate, and renders PERCENTILE_CONT as the interpolating quantileExactInclusive.

Timestamps on SQLite

SQLite has no timestamp type and stores timestamps as text without a UTC offset. A finality query compares the time column against the watermark, which the neutral AST emits as CAST('2025-03-13T23:59:59-04:00' AS TIMESTAMPTZ). Every other dialect understands that cast. SQLite would turn it into the integer 2025 and classify every row as past the watermark. The SQLite adapter therefore rewrites the cast into the watermark’s wall-clock text in its own timezone, here '2025-03-13 23:59:59'. The offset is dropped on purpose and the value is not converted to UTC. The watermark is defined as an instant in a business timezone, and a SQLite column holds local wall-clock values. Converting to UTC would shift the day boundary for date-only columns. If your SQLite timestamps are stored in UTC, express the watermark in UTC as well.

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.