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:Pipeline stages
- Bind the principal (only when a tenancy/role policy is loaded). Resolve the caller’s
Principalinto anEffectivePolicybefore any metric resolution. If tenancy is active and no tenant resolved, fail immediately withTENANT_UNRESOLVED: no partial work, no error message that could reveal whether a named metric exists. - Resolve metrics. Map each metric name → canonical binding → source/measure (or composite components). Unknown/ambiguous →
UNRESOLVED/AMBIGUOUSwith candidates. A metric outside the caller’s effective policy is folded intoUNRESOLVEDhere too, rather than a distinct forbidden code. See Tenancy & access control. - Resolve dimensions & filters. Bind to columns on the owning source or a join-reachable source. Unreachable →
UNREACHABLE. - 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.
<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.
- 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).
- 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. - Enforce guardrails. AND-in mandatory filters, apply
restrict_sourcefor the activecontext.severity: errorblocks, butwarnannotates. - Emit SQL. Build a dialect-neutral AST, then transpile via the dialect adapter. Read-only (
SELECT) only, by construction. - 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.scopeblock (tenant, scoped/shared sources, roles,tenancy_exempt) derived from the predicates actually emitted in stage 2b, not restated from the policy. - 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
expectwas calibrated against, independent of any tenancy policy.
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 whererentals 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:
candidates list is exact and actionable: pass one back to disambiguate, and the compiler joins precisely that path, nothing more:
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’skind (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 asCAST('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 explicitas_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.