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, 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 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.